Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82e53a6651 | |||
| e9dc1ede55 | |||
| 6ee1c46826 | |||
| 4f5c87bed9 | |||
| 7180031d1f | |||
| de4feb61cd | |||
| ddb9f2100b | |||
| 034bb3eb63 | |||
| 06a50880b7 | |||
| c66b57f9cb | |||
| ba30f84f49 | |||
| 81935daaf5 | |||
| d2260ac797 | |||
| ca6f39a3e8 | |||
| 5eb5bd426a | |||
| 08af3ed38d | |||
| cc5060d317 | |||
| c51e51c9c2 | |||
| f0ec9169c4 | |||
| 9615c50ccb | |||
| 9fcf2e2d1a | |||
| 67df87072d | |||
| ef249dfbe6 | |||
| 8bbbf6b9ac | |||
| 7f12034bff | |||
| 4430348168 | |||
| 578be7b6f4 | |||
| dbcd3fba91 | |||
| 0eb0bc0d41 | |||
| a73644b1da | |||
| 4c7a089753 | |||
| 4d70a98902 | |||
| f65f0b3603 | |||
| fec96cd049 | |||
| 25b180a2f3 |
@@ -46,7 +46,7 @@ create_file() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get_commit_range() {
|
get_commit_range() {
|
||||||
rm $TEMP_FILE_PATH/messages.txt
|
rm -f $TEMP_FILE_PATH/messages.txt
|
||||||
if [[ $LAST_TAG =~ $PATTERN ]]; then
|
if [[ $LAST_TAG =~ $PATTERN ]]; then
|
||||||
create_file true
|
create_file true
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ permissions:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
SKIP_MAKE_SETUP_CHECK: 'true'
|
SKIP_MAKE_SETUP_CHECK: 'true'
|
||||||
|
DOCKER_API_VERSION: '1.43'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -49,11 +50,15 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
- name: Login to Gitea container registry
|
- name: Login to Gitea container registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
|
env:
|
||||||
|
DOCKER_API_VERSION: ${{ env.DOCKER_API_VERSION }}
|
||||||
with:
|
with:
|
||||||
username: gitearobot
|
username: gitearobot
|
||||||
password: ${{ secrets.PACKAGE_GITEA_PAT }}
|
password: ${{ secrets.PACKAGE_GITEA_PAT }}
|
||||||
registry: git.disi.dev
|
registry: git.disi.dev
|
||||||
- name: Build and publish
|
- name: Build and publish
|
||||||
|
env:
|
||||||
|
DOCKER_API_VERSION: ${{ env.DOCKER_API_VERSION }}
|
||||||
run: |
|
run: |
|
||||||
REPOSITORY_OWNER=$(echo "$GITHUB_REPOSITORY" | awk -F '/' '{print $1}' | tr '[:upper:]' '[:lower:]')
|
REPOSITORY_OWNER=$(echo "$GITHUB_REPOSITORY" | awk -F '/' '{print $1}' | tr '[:upper:]' '[:lower:]')
|
||||||
REPOSITORY_NAME=$(echo "$GITHUB_REPOSITORY" | awk -F '/' '{print $2}' | tr '-' '_')
|
REPOSITORY_NAME=$(echo "$GITHUB_REPOSITORY" | awk -F '/' '{print $2}' | tr '-' '_')
|
||||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
sqlite.db
|
||||||
@@ -1,6 +1,43 @@
|
|||||||
FROM alpine
|
# AI Software Factory Dockerfile
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
# Set work directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY ./ai_software_factory/* /app
|
|
||||||
|
|
||||||
CMD ["sh", "/app/hello_world.sh"]
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY ./ai_software_factory/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY ./ai_software_factory .
|
||||||
|
|
||||||
|
# Set up environment file if it exists, otherwise use .env.example
|
||||||
|
# RUN if [ -f .env ]; then \
|
||||||
|
# cat .env; \
|
||||||
|
# elif [ -f .env.example ]; then \
|
||||||
|
# cp .env.example .env; \
|
||||||
|
# fi
|
||||||
|
|
||||||
|
# Initialize database tables (use SQLite by default, can be overridden by DB_POOL_SIZE env var)
|
||||||
|
# RUN python database.py || true
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
# Run application
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
|
|||||||
164
HISTORY.md
164
HISTORY.md
@@ -4,11 +4,175 @@ Changelog
|
|||||||
|
|
||||||
(unreleased)
|
(unreleased)
|
||||||
------------
|
------------
|
||||||
|
- Fix(ci): pin docker api version for release builds refs NOISSUE.
|
||||||
|
[Simon Diesenreiter]
|
||||||
|
|
||||||
|
|
||||||
|
0.4.0 (2026-04-10)
|
||||||
|
------------------
|
||||||
|
- Chore(git): ignore local sqlite database refs NOISSUE. [Simon
|
||||||
|
Diesenreiter]
|
||||||
|
- Feat(factory): implement db-backed dashboard and workflow automation
|
||||||
|
refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
|
||||||
|
0.3.6 (2026-04-04)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- Rename gitea workflow, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
|
||||||
|
0.3.5 (2026-04-04)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- Some cleanup, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
|
||||||
|
0.3.4 (2026-04-04)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- Fix database init, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
|
||||||
|
0.3.3 (2026-04-04)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- Fix runtime errors, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
|
||||||
|
0.3.2 (2026-04-04)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- Add back DB init endpoints, ref NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
|
||||||
|
0.3.1 (2026-04-04)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- Fix broken Docker build, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
|
||||||
|
0.3.0 (2026-04-04)
|
||||||
|
------------------
|
||||||
|
- Feat: dashboard via NiceGUI, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
|
||||||
|
0.2.2 (2026-04-04)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- Add missing jijna2 reference, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
|
||||||
|
0.2.1 (2026-04-04)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- Make dashbaord work, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
|
||||||
|
0.2.0 (2026-04-04)
|
||||||
|
------------------
|
||||||
|
- Feat: Add Python-native dashboard and main.py cleanup, refs NOISSUE.
|
||||||
|
[Simon Diesenreiter]
|
||||||
|
|
||||||
|
|
||||||
|
0.1.8 (2026-04-04)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- Broken python module references, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
|
||||||
|
0.1.7 (2026-04-04)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- More bugfixes, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
|
||||||
|
0.1.6 (2026-04-04)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- Proper containerfile, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
- Chore: update Containerfile to start the app instead of hello world
|
||||||
|
refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
|
||||||
|
0.1.5 (2026-04-04)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- Bugfix in version generation, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
- Feat(ai-software-factory): add n8n setup agent and enhance
|
||||||
|
orchestration refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
|
||||||
|
0.1.4 (2026-04-02)
|
||||||
|
------------------
|
||||||
|
|
||||||
Fix
|
Fix
|
||||||
~~~
|
~~~
|
||||||
- Fix container build, refs NOISSUE. [Simon Diesenreiter]
|
- Fix container build, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
|
||||||
0.1.3 (2026-04-02)
|
0.1.3 (2026-04-02)
|
||||||
------------------
|
------------------
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -1,5 +1,7 @@
|
|||||||
.ONESHELL:
|
.ONESHELL:
|
||||||
|
|
||||||
|
DOCKER_API_VERSION ?= 1.43
|
||||||
|
|
||||||
.PHONY: issetup
|
.PHONY: issetup
|
||||||
issetup:
|
issetup:
|
||||||
@[ -f .git/hooks/commit-msg ] || [ -z ${SKIP_MAKE_SETUP_CHECK+x} ] || (echo "You must run 'make setup' first to initialize the repo!" && exit 1)
|
@[ -f .git/hooks/commit-msg ] || [ -z ${SKIP_MAKE_SETUP_CHECK+x} ] || (echo "You must run 'make setup' first to initialize the repo!" && exit 1)
|
||||||
@@ -42,7 +44,7 @@ release: issetup ## Create a new tag for release.
|
|||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: issetup ## Create a new tag for release.
|
build: issetup ## Create a new tag for release.
|
||||||
@docker build -t ai-software-factory:$(cat ai-software-factory/VERSION) -f Containerfile .
|
@DOCKER_API_VERSION=$(DOCKER_API_VERSION) docker build -t ai-software-factory:$(cat ai_software_factory/VERSION) -f Containerfile .
|
||||||
|
|
||||||
# This project has been generated from rochacbruno/python-project-template
|
# This project has been generated from rochacbruno/python-project-template
|
||||||
#igest__ = 'rochacbruno'
|
#igest__ = 'rochacbruno'
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ TELEGRAM_CHAT_ID=your_chat_id
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build Docker image
|
# Build Docker image
|
||||||
docker build -t ai-software-factory -f Containerfile .
|
DOCKER_API_VERSION=1.43 docker build -t ai-software-factory -f Containerfile .
|
||||||
|
|
||||||
# Run with Docker Compose
|
# Run with Docker Compose
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|||||||
@@ -10,13 +10,20 @@ OLLAMA_URL=http://localhost:11434
|
|||||||
OLLAMA_MODEL=llama3
|
OLLAMA_MODEL=llama3
|
||||||
|
|
||||||
# Gitea
|
# Gitea
|
||||||
|
# Configure Gitea API for your organization
|
||||||
|
# GITEA_URL can be left empty to use GITEA_ORGANIZATION instead of GITEA_OWNER
|
||||||
GITEA_URL=https://gitea.yourserver.com
|
GITEA_URL=https://gitea.yourserver.com
|
||||||
GITEA_TOKEN=your_gitea_api_token
|
GITEA_TOKEN=your_gitea_api_token
|
||||||
GITEA_OWNER=ai-test
|
GITEA_OWNER=your_organization_name
|
||||||
GITEA_REPO=ai-test
|
GITEA_REPO= (optional - leave empty for any repo, or specify a default)
|
||||||
|
|
||||||
# n8n
|
# n8n
|
||||||
|
# n8n webhook for Telegram integration
|
||||||
N8N_WEBHOOK_URL=http://n8n.yourserver.com/webhook/telegram
|
N8N_WEBHOOK_URL=http://n8n.yourserver.com/webhook/telegram
|
||||||
|
# n8n API for automatic webhook configuration
|
||||||
|
N8N_API_URL=http://n8n.yourserver.com
|
||||||
|
N8N_USER=n8n_admin
|
||||||
|
N8N_PASSWORD=your_secure_password
|
||||||
|
|
||||||
# Telegram
|
# Telegram
|
||||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
|
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"dark_mode":false}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"dark_mode":false}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# AI Software Factory Dockerfile
|
|
||||||
FROM python:3.11-slim
|
|
||||||
|
|
||||||
# Set environment variables
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONUNBUFFERED=1 \
|
|
||||||
PIP_NO_CACHE_DIR=1 \
|
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
|
||||||
|
|
||||||
# Set work directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy application code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Set up environment file if it exists, otherwise use .env.example
|
|
||||||
RUN if [ -f .env ]; then \
|
|
||||||
cat .env; \
|
|
||||||
elif [ -f .env.example ]; then \
|
|
||||||
cp .env.example .env; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Initialize database tables (use SQLite by default, can be overridden by DB_POOL_SIZE env var)
|
|
||||||
RUN python database.py || true
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD curl -f http://localhost:8000/health || exit 1
|
|
||||||
|
|
||||||
# Run application
|
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
|
||||||
28
ai_software_factory/Makefile
Normal file
28
ai_software_factory/Makefile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
.PHONY: help run-api run-frontend run-tests init-db clean
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Available targets:"
|
||||||
|
@echo " make run-api - Run FastAPI app with NiceGUI frontend (default)"
|
||||||
|
@echo " make run-tests - Run pytest tests"
|
||||||
|
@echo " make init-db - Initialize database"
|
||||||
|
@echo " make clean - Remove container volumes"
|
||||||
|
@echo " make rebuild - Rebuild and run container"
|
||||||
|
|
||||||
|
run-api:
|
||||||
|
@echo "Starting FastAPI app with NiceGUI frontend..."
|
||||||
|
@bash start.sh dev
|
||||||
|
|
||||||
|
run-frontend:
|
||||||
|
@echo "NiceGUI is now integrated with FastAPI - use 'make run-api' to start everything together"
|
||||||
|
|
||||||
|
run-tests:
|
||||||
|
pytest -v
|
||||||
|
|
||||||
|
init-db:
|
||||||
|
@python -c "from main import app; from database import init_db; init_db()"
|
||||||
|
|
||||||
|
clean:
|
||||||
|
@echo "Cleaning up..."
|
||||||
|
@docker-compose down -v
|
||||||
|
|
||||||
|
rebuild: clean run-api
|
||||||
@@ -1 +1 @@
|
|||||||
0.1.4
|
0.4.1
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""AI Software Factory agents."""
|
"""AI Software Factory agents."""
|
||||||
|
|
||||||
from ai_software_factory.agents.orchestrator import AgentOrchestrator
|
from agents.orchestrator import AgentOrchestrator
|
||||||
from ai_software_factory.agents.git_manager import GitManager
|
from agents.git_manager import GitManager
|
||||||
from ai_software_factory.agents.ui_manager import UIManager
|
from agents.ui_manager import UIManager
|
||||||
from ai_software_factory.agents.telegram import TelegramHandler
|
from agents.telegram import TelegramHandler
|
||||||
from ai_software_factory.agents.gitea import GiteaAPI
|
from agents.gitea import GiteaAPI
|
||||||
from ai_software_factory.agents.database_manager import DatabaseManager
|
from agents.database_manager import DatabaseManager
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AgentOrchestrator",
|
"AgentOrchestrator",
|
||||||
|
|||||||
@@ -2,10 +2,33 @@
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from ai_software_factory.database import get_db
|
|
||||||
from ai_software_factory.models import (
|
try:
|
||||||
ProjectHistory, ProjectLog, UISnapshot, PullRequestData, SystemLog, UserAction, AuditTrail, PullRequest, ProjectStatus
|
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
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -61,6 +84,21 @@ class DatabaseManager:
|
|||||||
self.db = db
|
self.db = db
|
||||||
self.migrations = DatabaseMigrations(self.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:
|
def log_project_start(self, project_id: str, project_name: str, description: str) -> ProjectHistory:
|
||||||
"""Log project start."""
|
"""Log project start."""
|
||||||
history = ProjectHistory(
|
history = ProjectHistory(
|
||||||
@@ -87,6 +125,63 @@ class DatabaseManager:
|
|||||||
|
|
||||||
return history
|
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:
|
def log_progress_update(self, history_id: int, progress: int, step: str, message: str) -> None:
|
||||||
"""Log progress update."""
|
"""Log progress update."""
|
||||||
history = self.db.query(ProjectHistory).filter(
|
history = self.db.query(ProjectHistory).filter(
|
||||||
@@ -121,6 +216,8 @@ class DatabaseManager:
|
|||||||
|
|
||||||
if history:
|
if history:
|
||||||
history.status = ProjectStatus.COMPLETED.value
|
history.status = ProjectStatus.COMPLETED.value
|
||||||
|
history.progress = 100
|
||||||
|
history.current_step = "Completed"
|
||||||
history.completed_at = datetime.utcnow()
|
history.completed_at = datetime.utcnow()
|
||||||
history.message = message
|
history.message = message
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
@@ -300,7 +397,7 @@ class DatabaseManager:
|
|||||||
"actor": audit.actor,
|
"actor": audit.actor,
|
||||||
"action_type": audit.action_type,
|
"action_type": audit.action_type,
|
||||||
"details": audit.details,
|
"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
|
"timestamp": audit.created_at.isoformat() if audit.created_at else None
|
||||||
}
|
}
|
||||||
for audit in audits
|
for audit in audits
|
||||||
@@ -317,7 +414,7 @@ class DatabaseManager:
|
|||||||
"actor": audit.actor,
|
"actor": audit.actor,
|
||||||
"action_type": audit.action_type,
|
"action_type": audit.action_type,
|
||||||
"details": audit.details,
|
"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
|
"timestamp": audit.created_at.isoformat() if audit.created_at else None
|
||||||
}
|
}
|
||||||
for audit in audits
|
for audit in audits
|
||||||
@@ -387,7 +484,9 @@ class DatabaseManager:
|
|||||||
]
|
]
|
||||||
|
|
||||||
def log_code_change(self, project_id: str, change_type: str, file_path: str,
|
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."""
|
"""Log a code change."""
|
||||||
audit = AuditTrail(
|
audit = AuditTrail(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
@@ -396,12 +495,168 @@ class DatabaseManager:
|
|||||||
action_type=change_type,
|
action_type=change_type,
|
||||||
details=f"File {file_path} {change_type}",
|
details=f"File {file_path} {change_type}",
|
||||||
message=f"Code change: {file_path}",
|
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.add(audit)
|
||||||
self.db.commit()
|
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
|
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,
|
def log_commit(self, project_id: str, commit_message: str, actor: str,
|
||||||
actor_type: str = "agent") -> AuditTrail:
|
actor_type: str = "agent") -> AuditTrail:
|
||||||
"""Log a git commit."""
|
"""Log a git commit."""
|
||||||
@@ -429,7 +684,10 @@ class DatabaseManager:
|
|||||||
"project": None,
|
"project": None,
|
||||||
"logs": [],
|
"logs": [],
|
||||||
"actions": [],
|
"actions": [],
|
||||||
"audit_trail": []
|
"audit_trail": [],
|
||||||
|
"prompts": [],
|
||||||
|
"code_changes": [],
|
||||||
|
"prompt_change_correlations": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get logs
|
# Get logs
|
||||||
@@ -447,6 +705,10 @@ class DatabaseManager:
|
|||||||
AuditTrail.project_id == project_id
|
AuditTrail.project_id == project_id
|
||||||
).order_by(AuditTrail.created_at.desc()).all()
|
).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 {
|
return {
|
||||||
"project": {
|
"project": {
|
||||||
"id": history.id,
|
"id": history.id,
|
||||||
@@ -489,10 +751,92 @@ class DatabaseManager:
|
|||||||
"actor": audit.actor,
|
"actor": audit.actor,
|
||||||
"action_type": audit.action_type,
|
"action_type": audit.action_type,
|
||||||
"details": audit.details,
|
"details": audit.details,
|
||||||
|
"metadata": self._normalize_metadata(audit.metadata_json),
|
||||||
"timestamp": audit.created_at.isoformat() if audit.created_at else None
|
"timestamp": audit.created_at.isoformat() if audit.created_at else None
|
||||||
}
|
}
|
||||||
for audit in audit_trails
|
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:
|
def cleanup_audit_trail(self) -> None:
|
||||||
|
|||||||
@@ -2,8 +2,14 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ..config import settings
|
||||||
|
except ImportError:
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
|
||||||
class GitManager:
|
class GitManager:
|
||||||
"""Manages git operations for the project."""
|
"""Manages git operations for the project."""
|
||||||
@@ -12,7 +18,15 @@ class GitManager:
|
|||||||
if not project_id:
|
if not project_id:
|
||||||
raise ValueError("project_id cannot be empty or None")
|
raise ValueError("project_id cannot be empty or None")
|
||||||
self.project_id = project_id
|
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):
|
def init_repo(self):
|
||||||
"""Initialize git repository."""
|
"""Initialize git repository."""
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Gitea API integration for commits and PRs."""
|
"""Gitea API integration for commits and PRs."""
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -8,23 +7,69 @@ from typing import Optional
|
|||||||
class GiteaAPI:
|
class GiteaAPI:
|
||||||
"""Gitea API client for repository operations."""
|
"""Gitea API client for repository operations."""
|
||||||
|
|
||||||
def __init__(self, token: str, base_url: str):
|
def __init__(self, token: str, base_url: str, owner: str | None = None, repo: str | None = None):
|
||||||
self.token = token
|
self.token = token
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.owner = owner
|
||||||
|
self.repo = repo
|
||||||
self.headers = {
|
self.headers = {
|
||||||
"Authorization": f"token {token}",
|
"Authorization": f"token {token}",
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
}
|
}
|
||||||
|
|
||||||
async def create_branch(self, owner: str, repo: str, branch: str, base: str = "main"):
|
def get_config(self) -> dict:
|
||||||
"""Create a new branch."""
|
"""Load configuration from environment."""
|
||||||
url = f"{self.base_url}/repos/{owner}/{repo}/branches/{branch}"
|
base_url = os.getenv("GITEA_URL", "https://gitea.local")
|
||||||
|
token = os.getenv("GITEA_TOKEN", "")
|
||||||
|
owner = os.getenv("GITEA_OWNER", "ai-test")
|
||||||
|
repo = os.getenv("GITEA_REPO", "")
|
||||||
|
|
||||||
|
# Allow empty repo for any repo mode (org/repo pattern)
|
||||||
|
if not repo:
|
||||||
|
repo = "any-repo" # Use this as a placeholder for org/repo operations
|
||||||
|
|
||||||
|
# Check for repo suffix pattern (e.g., repo-* for multiple repos)
|
||||||
|
repo_suffix = os.getenv("GITEA_REPO_SUFFIX", "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"base_url": base_url.rstrip("/"),
|
||||||
|
"token": token,
|
||||||
|
"owner": owner,
|
||||||
|
"repo": repo,
|
||||||
|
"repo_suffix": repo_suffix,
|
||||||
|
"supports_any_repo": not repo or repo_suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_auth_headers(self) -> dict:
|
||||||
|
"""Get authentication headers."""
|
||||||
|
return {
|
||||||
|
"Authorization": f"token {self.token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
async def create_branch(self, branch: str, base: str = "main", owner: str | None = None, repo: str | None = None):
|
||||||
|
"""Create a new branch.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
branch: Branch name to create
|
||||||
|
base: Base branch to create from (default: "main")
|
||||||
|
owner: Organization/owner name (optional, falls back to configured owner)
|
||||||
|
repo: Repository name (optional, falls back to configured repo)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
API response or error message
|
||||||
|
"""
|
||||||
|
# Use provided owner/repo or fall back to configured values
|
||||||
|
_owner = owner or self.owner
|
||||||
|
_repo = repo or self.repo
|
||||||
|
|
||||||
|
url = f"{self.base_url}/repos/{_owner}/{_repo}/branches/{branch}"
|
||||||
payload = {"base": base}
|
payload = {"base": base}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import aiohttp
|
import aiohttp
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.post(url, headers=self.headers, json=payload) as resp:
|
async with session.post(url, headers=self.get_auth_headers(), json=payload) as resp:
|
||||||
if resp.status == 201:
|
if resp.status == 201:
|
||||||
return await resp.json()
|
return await resp.json()
|
||||||
else:
|
else:
|
||||||
@@ -34,27 +79,42 @@ class GiteaAPI:
|
|||||||
|
|
||||||
async def create_pull_request(
|
async def create_pull_request(
|
||||||
self,
|
self,
|
||||||
owner: str,
|
|
||||||
repo: str,
|
|
||||||
title: str,
|
title: str,
|
||||||
body: str,
|
body: str,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
base: str = "main",
|
base: str = "main",
|
||||||
head: str = None
|
head: str | None = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a pull request."""
|
"""Create a pull request.
|
||||||
url = f"{self.base_url}/repos/{owner}/{repo}/pulls"
|
|
||||||
|
Args:
|
||||||
|
title: PR title
|
||||||
|
body: PR description
|
||||||
|
owner: Organization/owner name
|
||||||
|
repo: Repository name
|
||||||
|
base: Base branch (default: "main")
|
||||||
|
head: Head branch (optional, auto-generated if not provided)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
API response or error message
|
||||||
|
"""
|
||||||
|
_owner = owner or self.owner
|
||||||
|
_repo = repo or self.repo
|
||||||
|
|
||||||
|
url = f"{self.base_url}/repos/{_owner}/{_repo}/pulls"
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"title": title,
|
"title": title,
|
||||||
"body": body,
|
"body": body,
|
||||||
"base": {"branch": base},
|
"base": {"branch": base},
|
||||||
"head": head or f"ai-gen-{hash(title) % 10000}"
|
"head": head or f"{_owner}-{_repo}-ai-gen-{hash(title) % 10000}"
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import aiohttp
|
import aiohttp
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.post(url, headers=self.headers, json=payload) as resp:
|
async with session.post(url, headers=self.get_auth_headers(), json=payload) as resp:
|
||||||
if resp.status == 201:
|
if resp.status == 201:
|
||||||
return await resp.json()
|
return await resp.json()
|
||||||
else:
|
else:
|
||||||
@@ -64,52 +124,67 @@ class GiteaAPI:
|
|||||||
|
|
||||||
async def push_commit(
|
async def push_commit(
|
||||||
self,
|
self,
|
||||||
owner: str,
|
|
||||||
repo: str,
|
|
||||||
branch: str,
|
branch: str,
|
||||||
files: list[dict],
|
files: list[dict],
|
||||||
message: str
|
message: str,
|
||||||
|
owner: str | None = None,
|
||||||
|
repo: str | None = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Push files to a branch.
|
Push files to a branch.
|
||||||
|
|
||||||
In production, this would use gitea's API or git push.
|
In production, this would use gitea's API or git push.
|
||||||
For now, we'll simulate the operation.
|
For now, we'll simulate the operation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
branch: Branch name
|
||||||
|
files: List of files to push
|
||||||
|
message: Commit message
|
||||||
|
owner: Organization/owner name (optional, falls back to configured owner)
|
||||||
|
repo: Repository name (optional, falls back to configured repo)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Status response
|
||||||
"""
|
"""
|
||||||
# In reality, you'd need to:
|
# Use provided owner/repo or fall back to configured values
|
||||||
# 1. Clone repo
|
_owner = owner or self.owner
|
||||||
# 2. Create branch
|
_repo = repo or self.repo
|
||||||
# 3. Add files
|
|
||||||
# 4. Commit
|
|
||||||
# 5. Push
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "simulated",
|
"status": "simulated",
|
||||||
"branch": branch,
|
"branch": branch,
|
||||||
"message": message,
|
"message": message,
|
||||||
"files": files
|
"files": files,
|
||||||
|
"owner": _owner,
|
||||||
|
"repo": _repo
|
||||||
}
|
}
|
||||||
|
|
||||||
async def get_repo_info(self, owner: str, repo: str) -> dict:
|
async def get_repo_info(self, owner: str | None = None, repo: str | None = None) -> dict:
|
||||||
"""Get repository information."""
|
"""Get repository information.
|
||||||
url = f"{self.base_url}/repos/{owner}/{repo}"
|
|
||||||
|
Args:
|
||||||
|
owner: Organization/owner name (optional, falls back to configured owner)
|
||||||
|
repo: Repository name (optional, falls back to configured repo)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Repository info or error message
|
||||||
|
"""
|
||||||
|
# Use provided owner/repo or fall back to configured values
|
||||||
|
_owner = owner or self.owner
|
||||||
|
_repo = repo or self.repo
|
||||||
|
|
||||||
|
if not _repo:
|
||||||
|
return {"error": "Repository name required for org operations"}
|
||||||
|
|
||||||
|
url = f"{self.base_url}/repos/{_owner}/{_repo}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import aiohttp
|
import aiohttp
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.get(url, headers=self.headers) as resp:
|
async with session.get(url, headers=self.get_auth_headers()) as resp:
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
return await resp.json()
|
return await resp.json()
|
||||||
else:
|
else:
|
||||||
return {"error": await resp.text()}
|
return {"error": await resp.text()}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
def get_config(self) -> dict:
|
|
||||||
"""Load configuration from environment."""
|
|
||||||
return {
|
|
||||||
"base_url": os.getenv("GITEA_URL", "https://gitea.local"),
|
|
||||||
"token": os.getenv("GITEA_TOKEN", ""),
|
|
||||||
"owner": os.getenv("GITEA_OWNER", "ai-test"),
|
|
||||||
"repo": os.getenv("GITEA_REPO", "ai-test")
|
|
||||||
}
|
|
||||||
|
|||||||
379
ai_software_factory/agents/n8n_setup.py
Normal file
379
ai_software_factory/agents/n8n_setup.py
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
"""n8n setup agent for automatic webhook configuration."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ..config import settings
|
||||||
|
except ImportError:
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class N8NSetupAgent:
|
||||||
|
"""Automatically configures n8n webhooks and workflows using API token authentication."""
|
||||||
|
|
||||||
|
def __init__(self, api_url: str, webhook_token: str):
|
||||||
|
"""Initialize n8n setup agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_url: n8n API URL (e.g., http://n8n.yourserver.com)
|
||||||
|
webhook_token: n8n webhook token for API access (more secure than username/password)
|
||||||
|
|
||||||
|
Note: Set the webhook token in n8n via Settings > Credentials > Webhook
|
||||||
|
This token is used for all API requests instead of Basic Auth
|
||||||
|
"""
|
||||||
|
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."""
|
||||||
|
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."""
|
||||||
|
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."""
|
||||||
|
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."""
|
||||||
|
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."""
|
||||||
|
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.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
webhook_path: The webhook path (e.g., /webhook/telegram)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result of setup operation
|
||||||
|
"""
|
||||||
|
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."""
|
||||||
|
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,
|
||||||
|
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")}
|
||||||
|
|
||||||
|
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",
|
||||||
|
}
|
||||||
@@ -1,14 +1,24 @@
|
|||||||
"""Agent orchestrator for software generation."""
|
"""Agent orchestrator for software generation."""
|
||||||
|
|
||||||
import asyncio
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import py_compile
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from ai_software_factory.agents.git_manager import GitManager
|
|
||||||
from ai_software_factory.agents.ui_manager import UIManager
|
|
||||||
from ai_software_factory.agents.gitea import GiteaAPI
|
|
||||||
from ai_software_factory.agents.database_manager import DatabaseManager
|
|
||||||
from ai_software_factory.config import settings
|
|
||||||
from datetime import datetime
|
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:
|
class AgentOrchestrator:
|
||||||
@@ -21,7 +31,9 @@ class AgentOrchestrator:
|
|||||||
description: str,
|
description: str,
|
||||||
features: list,
|
features: list,
|
||||||
tech_stack: list,
|
tech_stack: list,
|
||||||
db = None
|
db=None,
|
||||||
|
prompt_text: str | None = None,
|
||||||
|
prompt_actor: str = "api",
|
||||||
):
|
):
|
||||||
"""Initialize orchestrator."""
|
"""Initialize orchestrator."""
|
||||||
self.project_id = project_id
|
self.project_id = project_id
|
||||||
@@ -36,13 +48,20 @@ class AgentOrchestrator:
|
|||||||
self.logs = []
|
self.logs = []
|
||||||
self.ui_data = {}
|
self.ui_data = {}
|
||||||
self.db = db
|
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
|
# Initialize agents
|
||||||
self.git_manager = GitManager(project_id)
|
self.git_manager = GitManager(project_id)
|
||||||
self.ui_manager = UIManager(project_id)
|
self.ui_manager = UIManager(project_id)
|
||||||
self.gitea_api = GiteaAPI(
|
self.gitea_api = GiteaAPI(
|
||||||
token=settings.GITEA_TOKEN,
|
token=settings.GITEA_TOKEN,
|
||||||
base_url=settings.GITEA_URL
|
base_url=settings.GITEA_URL,
|
||||||
|
owner=settings.GITEA_OWNER,
|
||||||
|
repo=settings.GITEA_REPO or ""
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize database manager if db session provided
|
# Initialize database manager if db session provided
|
||||||
@@ -58,52 +77,115 @@ class AgentOrchestrator:
|
|||||||
)
|
)
|
||||||
# Re-fetch with new history_id
|
# Re-fetch with new history_id
|
||||||
self.db_manager = DatabaseManager(db)
|
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:
|
async def run(self) -> dict:
|
||||||
"""Run the software generation process with full audit logging."""
|
"""Run the software generation process with full audit logging."""
|
||||||
try:
|
try:
|
||||||
# Step 1: Initialize project
|
# Step 1: Initialize project
|
||||||
self.progress = 5
|
self.status = "running"
|
||||||
self.current_step = "Initializing project"
|
self._update_progress(5, "initializing", "Setting up project structure...")
|
||||||
self.message = "Setting up project structure..."
|
self._append_log("Initializing project.")
|
||||||
self.logs.append(f"[{datetime.utcnow().isoformat()}] Initializing project.")
|
|
||||||
|
|
||||||
# Step 2: Create project structure (skip git operations)
|
# Step 2: Create project structure (skip git operations)
|
||||||
self.progress = 15
|
self._update_progress(20, "project-structure", "Creating project files...")
|
||||||
self.current_step = "Creating project structure"
|
|
||||||
self.message = "Creating project files..."
|
|
||||||
await self._create_project_structure()
|
await self._create_project_structure()
|
||||||
|
|
||||||
# Step 3: Generate initial code
|
# Step 3: Generate initial code
|
||||||
self.progress = 25
|
self._update_progress(55, "code-generation", "Generating project entrypoint and tests...")
|
||||||
self.current_step = "Generating initial code"
|
|
||||||
self.message = "Generating initial code with Ollama..."
|
|
||||||
await self._generate_code()
|
await self._generate_code()
|
||||||
|
|
||||||
# Step 4: Test the code
|
# Step 4: Test the code
|
||||||
self.progress = 50
|
self._update_progress(80, "validation", "Validating generated code...")
|
||||||
self.current_step = "Testing code"
|
|
||||||
self.message = "Running tests..."
|
|
||||||
await self._run_tests()
|
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
|
# Step 7: Complete
|
||||||
self.progress = 100
|
self.status = "completed"
|
||||||
self.current_step = "Completed"
|
self._update_progress(100, "completed", "Software generation complete!")
|
||||||
self.message = "Software generation complete!"
|
self._append_log("Software generation complete!")
|
||||||
self.logs.append(f"[{datetime.utcnow().isoformat()}] Software generation complete!")
|
self.ui_manager.ui_data["changed_files"] = list(dict.fromkeys(self.changed_files))
|
||||||
|
|
||||||
# Log completion to database if available
|
# Log completion to database if available
|
||||||
if self.db_manager and self.history:
|
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(
|
self.db_manager.log_project_complete(
|
||||||
history_id=self.history.id,
|
history_id=self.history.id,
|
||||||
message="Software generation complete!"
|
message="Software generation complete!"
|
||||||
@@ -116,13 +198,15 @@ class AgentOrchestrator:
|
|||||||
"current_step": self.current_step,
|
"current_step": self.current_step,
|
||||||
"logs": self.logs,
|
"logs": self.logs,
|
||||||
"ui_data": self.ui_manager.ui_data,
|
"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:
|
except Exception as e:
|
||||||
self.status = "error"
|
self.status = "error"
|
||||||
self.message = f"Error: {str(e)}"
|
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
|
# Log error to database if available
|
||||||
if self.db_manager and self.history:
|
if self.db_manager and self.history:
|
||||||
@@ -139,64 +223,32 @@ class AgentOrchestrator:
|
|||||||
"logs": self.logs,
|
"logs": self.logs,
|
||||||
"error": str(e),
|
"error": str(e),
|
||||||
"ui_data": self.ui_manager.ui_data,
|
"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:
|
async def _create_project_structure(self) -> None:
|
||||||
"""Create initial project structure."""
|
"""Create initial project structure."""
|
||||||
project_dir = self.project_id
|
self.project_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
for relative_path, content in self._template_files().items():
|
||||||
# Create .gitignore
|
if relative_path.startswith("main.py") or relative_path.startswith("tests/"):
|
||||||
gitignore_path = f"{project_dir}/.gitignore"
|
continue
|
||||||
try:
|
self._write_file(relative_path, content)
|
||||||
os.makedirs(project_dir, exist_ok=True)
|
self._append_log(f"Project structure created under {self.project_root}.")
|
||||||
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)}")
|
|
||||||
|
|
||||||
async def _generate_code(self) -> None:
|
async def _generate_code(self) -> None:
|
||||||
"""Generate code using Ollama."""
|
"""Generate code using Ollama."""
|
||||||
# This would call Ollama API to generate code
|
for relative_path, content in self._template_files().items():
|
||||||
# For now, create a placeholder file
|
if relative_path in {"main.py", "tests/test_app.py"}:
|
||||||
try:
|
self._write_file(relative_path, content)
|
||||||
main_py_path = f"{self.project_id}/main.py"
|
self._append_log("Application entrypoint and smoke test generated.")
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _run_tests(self) -> None:
|
async def _run_tests(self) -> None:
|
||||||
"""Run tests for the generated code."""
|
"""Run tests for the generated code."""
|
||||||
# This would run pytest or other test framework
|
py_compile.compile(str(self.project_root / "main.py"), doraise=True)
|
||||||
# For now, simulate test success
|
py_compile.compile(str(self.project_root / "tests/test_app.py"), doraise=True)
|
||||||
pass
|
self._append_log("Generated Python files compiled successfully.")
|
||||||
|
|
||||||
async def _commit_to_git(self) -> None:
|
async def _commit_to_git(self) -> None:
|
||||||
"""Commit changes to git."""
|
"""Commit changes to git."""
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""UI manager for web dashboard with audit trail display."""
|
"""UI manager for web dashboard with audit trail display."""
|
||||||
|
|
||||||
|
import html
|
||||||
import json
|
import json
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
@@ -50,14 +51,7 @@ class UIManager:
|
|||||||
"""Escape HTML special characters for safe display."""
|
"""Escape HTML special characters for safe display."""
|
||||||
if text is None:
|
if text is None:
|
||||||
return ""
|
return ""
|
||||||
safe_chars = {
|
return html.escape(str(text), quote=True)
|
||||||
'&': '&',
|
|
||||||
'<': '<',
|
|
||||||
'>': '>',
|
|
||||||
'"': '"',
|
|
||||||
"'": '''
|
|
||||||
}
|
|
||||||
return ''.join(safe_chars.get(c, c) for c in str(text))
|
|
||||||
|
|
||||||
def render_dashboard(self, audit_trail: Optional[List[dict]] = None,
|
def render_dashboard(self, audit_trail: Optional[List[dict]] = None,
|
||||||
actions: Optional[List[dict]] = None,
|
actions: Optional[List[dict]] = None,
|
||||||
|
|||||||
36
ai_software_factory/alembic.ini
Normal file
36
ai_software_factory/alembic.ini
Normal file
@@ -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
|
||||||
50
ai_software_factory/alembic/env.py
Normal file
50
ai_software_factory/alembic/env.py
Normal file
@@ -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()
|
||||||
17
ai_software_factory/alembic/script.py.mako
Normal file
17
ai_software_factory/alembic/script.py.mako
Normal file
@@ -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"}
|
||||||
@@ -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")
|
||||||
@@ -27,6 +27,15 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# n8n settings
|
# n8n settings
|
||||||
N8N_WEBHOOK_URL: str = ""
|
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 settings
|
||||||
TELEGRAM_BOT_TOKEN: str = ""
|
TELEGRAM_BOT_TOKEN: str = ""
|
||||||
@@ -101,6 +110,21 @@ class Settings(BaseSettings):
|
|||||||
"""Get n8n webhook URL with trimmed whitespace."""
|
"""Get n8n webhook URL with trimmed whitespace."""
|
||||||
return self.N8N_WEBHOOK_URL.strip()
|
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
|
@property
|
||||||
def telegram_bot_token(self) -> str:
|
def telegram_bot_token(self) -> str:
|
||||||
"""Get Telegram bot token with trimmed whitespace."""
|
"""Get Telegram bot token with trimmed whitespace."""
|
||||||
@@ -111,6 +135,18 @@ class Settings(BaseSettings):
|
|||||||
"""Get Telegram chat ID with trimmed whitespace."""
|
"""Get Telegram chat ID with trimmed whitespace."""
|
||||||
return self.TELEGRAM_CHAT_ID.strip()
|
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
|
@property
|
||||||
def postgres_host(self) -> str:
|
def postgres_host(self) -> str:
|
||||||
"""Get PostgreSQL host."""
|
"""Get PostgreSQL host."""
|
||||||
|
|||||||
272
ai_software_factory/dashboard_ui.py
Normal file
272
ai_software_factory/dashboard_ui.py
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
"""NiceGUI dashboard backed by real database state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import closing
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
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 the main NiceGUI dashboard."""
|
||||||
|
ui.add_head_html(
|
||||||
|
"""
|
||||||
|
<style>
|
||||||
|
body { background: radial-gradient(circle at top, #f4efe7 0%, #e9e1d4 38%, #d7cec1 100%); }
|
||||||
|
.factory-shell { max-width: 1240px; margin: 0 auto; }
|
||||||
|
.factory-panel { background: rgba(255,255,255,0.78); backdrop-filter: blur(18px); border: 1px solid rgba(73,54,40,0.10); border-radius: 24px; box-shadow: 0 24px 60px rgba(84,55,24,0.14); }
|
||||||
|
.factory-kpi { background: linear-gradient(145deg, rgba(63,94,78,0.94), rgba(29,52,45,0.92)); color: #f8f3eb; border-radius: 18px; padding: 18px; min-height: 128px; }
|
||||||
|
.factory-muted { color: #745e4c; }
|
||||||
|
.factory-code { font-family: 'IBM Plex Mono', 'Fira Code', monospace; background: rgba(32,26,20,0.92); color: #f4efe7; border-radius: 14px; padding: 12px; white-space: pre-wrap; }
|
||||||
|
.factory-chip { background: rgba(173, 129, 82, 0.14); color: #6b4b2e; border-radius: 999px; padding: 4px 10px; font-size: 12px; }
|
||||||
|
</style>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""Run the NiceGUI app."""
|
||||||
|
ui.run(title='AI Software Factory Dashboard', port=port, reload=reload, browser=browser, storage_secret=storage_secret)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ in {'__main__', '__console__'}:
|
||||||
|
create_dashboard()
|
||||||
|
run_app()
|
||||||
@@ -1,16 +1,28 @@
|
|||||||
"""Database connection and session management."""
|
"""Database connection and session management."""
|
||||||
|
|
||||||
from sqlalchemy import create_engine, event
|
from collections.abc import Generator
|
||||||
from sqlalchemy.orm import sessionmaker, Session
|
from pathlib import Path
|
||||||
from ai_software_factory.config import settings
|
|
||||||
from ai_software_factory.models import Base
|
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."""
|
"""Create and return SQLAlchemy engine with connection pooling."""
|
||||||
# Use SQLite for tests, PostgreSQL for production
|
# Use SQLite for tests, PostgreSQL for production
|
||||||
if settings.USE_SQLITE:
|
if settings.USE_SQLITE:
|
||||||
db_path = settings.SQLITE_DB_PATH or "/tmp/ai_software_factory_test.db"
|
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}"
|
db_url = f"sqlite:///{db_path}"
|
||||||
# SQLite-specific configuration - no pooling for SQLite
|
# SQLite-specific configuration - no pooling for SQLite
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
@@ -47,12 +59,11 @@ def get_engine() -> create_engine:
|
|||||||
return engine
|
return engine
|
||||||
|
|
||||||
|
|
||||||
def get_session() -> Session:
|
def get_session() -> Generator[Session, None, None]:
|
||||||
"""Create and return database session factory."""
|
"""Yield a managed database session."""
|
||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
def session_factory() -> Session:
|
|
||||||
session = SessionLocal()
|
session = SessionLocal()
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
@@ -63,33 +74,18 @@ def get_session() -> Session:
|
|||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
return session_factory
|
|
||||||
|
|
||||||
|
def get_db() -> Generator[Session, None, None]:
|
||||||
def init_db() -> None:
|
|
||||||
"""Initialize database tables."""
|
|
||||||
engine = get_engine()
|
|
||||||
Base.metadata.create_all(bind=engine)
|
|
||||||
print("Database tables created successfully.")
|
|
||||||
|
|
||||||
|
|
||||||
def drop_db() -> None:
|
|
||||||
"""Drop all database tables (use with caution!)."""
|
|
||||||
engine = get_engine()
|
|
||||||
Base.metadata.drop_all(bind=engine)
|
|
||||||
print("Database tables dropped successfully.")
|
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> Session:
|
|
||||||
"""Dependency for FastAPI routes that need database access."""
|
"""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()
|
engine = get_engine()
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
session = SessionLocal()
|
session = SessionLocal()
|
||||||
try:
|
return session
|
||||||
yield session
|
|
||||||
finally:
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
|
|
||||||
def get_db_session() -> Session:
|
def get_db_session() -> Session:
|
||||||
@@ -98,29 +94,115 @@ def get_db_session() -> Session:
|
|||||||
return 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:
|
||||||
|
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 with metadata fallback."}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error initializing SQLite database: {str(e)}")
|
||||||
|
return {'status': 'error', 'message': f'Error: {str(e)}'}
|
||||||
|
else:
|
||||||
|
# PostgreSQL
|
||||||
|
db_url = settings.POSTGRES_URL or settings.database_url
|
||||||
|
db_name = db_url.split('/')[-1] if '/' in db_url else 'ai_software_factory'
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create engine to check/create database
|
||||||
|
engine = create_engine(db_url)
|
||||||
|
|
||||||
|
# Try to create database if it doesn't exist
|
||||||
|
try:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# Check if database exists
|
||||||
|
result = conn.execute(text(f"SELECT 1 FROM {db_name} WHERE 1=0"))
|
||||||
|
# If no error, database exists
|
||||||
|
conn.commit()
|
||||||
|
print(f"PostgreSQL database '{db_name}' already exists.")
|
||||||
|
except Exception as e:
|
||||||
|
# Database doesn't exist or has different error - try to create it
|
||||||
|
error_msg = str(e).lower()
|
||||||
|
# Only create if it's a relation does not exist error or similar
|
||||||
|
if "does not exist" in error_msg or "database" in error_msg:
|
||||||
|
try:
|
||||||
|
conn = engine.connect()
|
||||||
|
conn.execute(text(f"CREATE DATABASE {db_name}"))
|
||||||
|
conn.commit()
|
||||||
|
print(f"PostgreSQL database '{db_name}' created.")
|
||||||
|
except Exception as db_error:
|
||||||
|
print(f"Could not create database: {str(db_error)}")
|
||||||
|
# Try to connect anyway - maybe using existing db name
|
||||||
|
engine = create_engine(db_url.replace(f'/{db_name}', '/postgres'))
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# Just create tables in postgres database for now
|
||||||
|
print(f"Using existing 'postgres' database.")
|
||||||
|
|
||||||
|
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)}")
|
||||||
|
return {'status': 'error', 'message': f'Error: {str(e)}'}
|
||||||
|
|
||||||
|
|
||||||
|
def drop_db() -> dict:
|
||||||
|
"""Drop all database tables (use with caution!)."""
|
||||||
|
if settings.USE_SQLITE:
|
||||||
|
engine = get_engine()
|
||||||
|
try:
|
||||||
|
Base.metadata.drop_all(bind=engine)
|
||||||
|
print("SQLite database tables dropped successfully.")
|
||||||
|
return {'status': 'success', 'message': 'SQLite tables dropped.'}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error dropping SQLite tables: {str(e)}")
|
||||||
|
return {'status': 'error', 'message': str(e)}
|
||||||
|
else:
|
||||||
|
db_url = settings.POSTGRES_URL or settings.database_url
|
||||||
|
db_name = db_url.split('/')[-1] if '/' in db_url else 'ai_software_factory'
|
||||||
|
|
||||||
|
try:
|
||||||
|
engine = create_engine(db_url)
|
||||||
|
Base.metadata.drop_all(bind=engine)
|
||||||
|
print(f"PostgreSQL database '{db_name}' tables dropped successfully.")
|
||||||
|
return {'status': 'success', 'message': f'PostgreSQL "{db_name}" tables dropped.'}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error dropping PostgreSQL tables: {str(e)}")
|
||||||
|
return {'status': 'error', 'message': str(e)}
|
||||||
|
|
||||||
|
|
||||||
def create_migration_script() -> str:
|
def create_migration_script() -> str:
|
||||||
"""Generate a migration script for database schema changes."""
|
"""Generate a migration script for database schema changes."""
|
||||||
return '''-- Migration script for AI Software Factory database
|
return """See ai_software_factory/alembic/versions for managed schema migrations."""
|
||||||
-- 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);
|
|
||||||
'''
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
ai-software-factory:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Containerfile
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
environment:
|
|
||||||
- HOST=0.0.0.0
|
|
||||||
- PORT=8000
|
|
||||||
- OLLAMA_URL=http://ollama:11434
|
|
||||||
- OLLAMA_MODEL=llama3
|
|
||||||
- GITEA_URL=${GITEA_URL:-https://gitea.yourserver.com}
|
|
||||||
- GITEA_TOKEN=${GITEA_TOKEN:-}
|
|
||||||
- GITEA_OWNER=${GITEA_OWNER:-ai-test}
|
|
||||||
- GITEA_REPO=${GITEA_REPO:-ai-test}
|
|
||||||
- N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-}
|
|
||||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
|
||||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
|
||||||
- POSTGRES_HOST=postgres
|
|
||||||
- POSTGRES_PORT=5432
|
|
||||||
- POSTGRES_USER=${POSTGRES_USER:-ai_software_factory}
|
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-}
|
|
||||||
- POSTGRES_DB=${POSTGRES_DB:-ai_software_factory}
|
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
|
||||||
- DB_POOL_SIZE=${DB_POOL_SIZE:-10}
|
|
||||||
- DB_MAX_OVERFLOW=${DB_MAX_OVERFLOW:-20}
|
|
||||||
- DB_POOL_RECYCLE=${DB_POOL_RECYCLE:-3600}
|
|
||||||
- DB_POOL_TIMEOUT=${DB_POOL_TIMEOUT:-30}
|
|
||||||
depends_on:
|
|
||||||
- postgres
|
|
||||||
networks:
|
|
||||||
- ai-test-network
|
|
||||||
|
|
||||||
postgres:
|
|
||||||
image: postgres:15-alpine
|
|
||||||
environment:
|
|
||||||
- POSTGRES_USER=${POSTGRES_USER:-ai_software_factory}
|
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-}
|
|
||||||
- POSTGRES_DB=${POSTGRES_DB:-ai_software_factory}
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
networks:
|
|
||||||
- ai-test-network
|
|
||||||
# Health check for PostgreSQL
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-ai_software_factory} -d ${POSTGRES_DB:-ai_software_factory}"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
n8n:
|
|
||||||
image: n8nio/n8n:latest
|
|
||||||
ports:
|
|
||||||
- "5678:5678"
|
|
||||||
environment:
|
|
||||||
- N8N_HOST=n8n
|
|
||||||
- N8N_PORT=5678
|
|
||||||
- N8N_PROTOCOL=http
|
|
||||||
volumes:
|
|
||||||
- n8n_data:/home/node/.n8n
|
|
||||||
networks:
|
|
||||||
- ai-test-network
|
|
||||||
|
|
||||||
ollama:
|
|
||||||
image: ollama/ollama:latest
|
|
||||||
ports:
|
|
||||||
- "11434:11434"
|
|
||||||
volumes:
|
|
||||||
- ollama_data:/root/.ollama
|
|
||||||
networks:
|
|
||||||
- ai-test-network
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
n8n_data:
|
|
||||||
ollama_data:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
ai-test-network:
|
|
||||||
driver: bridge
|
|
||||||
36
ai_software_factory/frontend.py
Normal file
36
ai_software_factory/frontend.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""Frontend module for NiceGUI with FastAPI integration.
|
||||||
|
|
||||||
|
This module provides the NiceGUI frontend that can be initialized with a FastAPI app.
|
||||||
|
The dashboard shown is from dashboard_ui.py with real-time database data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from nicegui import app, ui
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""Initialize the NiceGUI frontend with the FastAPI app.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fastapi_app: The FastAPI application instance.
|
||||||
|
storage_secret: Optional secret for persistent user storage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@ui.page('/show')
|
||||||
|
def show():
|
||||||
|
create_dashboard()
|
||||||
|
|
||||||
|
# NOTE dark mode will be persistent for each user across tabs and server restarts
|
||||||
|
ui.dark_mode().bind_value(app.storage.user, 'dark_mode')
|
||||||
|
ui.checkbox('dark mode').bind_value(app.storage.user, 'dark_mode')
|
||||||
|
|
||||||
|
ui.run_with(
|
||||||
|
fastapi_app,
|
||||||
|
storage_secret=storage_secret, # NOTE setting a secret is optional but allows for persistent storage per user
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,10 @@ from sqlalchemy import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy.orm import relationship, declarative_base
|
from sqlalchemy.orm import relationship, declarative_base
|
||||||
|
|
||||||
from ai_software_factory.config import settings
|
try:
|
||||||
|
from .config import settings
|
||||||
|
except ImportError:
|
||||||
|
from config import settings
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -52,6 +55,7 @@ class ProjectHistory(Base):
|
|||||||
ui_snapshots = relationship("UISnapshot", back_populates="project_history", cascade="all, delete-orphan")
|
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_requests = relationship("PullRequest", back_populates="project_history", cascade="all, delete-orphan")
|
||||||
pull_request_data = relationship("PullRequestData", 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):
|
class ProjectLog(Base):
|
||||||
@@ -145,6 +149,22 @@ class AuditTrail(Base):
|
|||||||
metadata_json = Column(JSON, nullable=True)
|
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):
|
class UserAction(Base):
|
||||||
"""User action audit entries."""
|
"""User action audit entries."""
|
||||||
__tablename__ = "user_actions"
|
__tablename__ = "user_actions"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
fastapi==0.109.0
|
fastapi>=0.135.3
|
||||||
uvicorn[standard]==0.27.0
|
uvicorn[standard]==0.27.0
|
||||||
sqlalchemy==2.0.25
|
sqlalchemy==2.0.25
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
pydantic==2.5.3
|
pydantic==2.12.5
|
||||||
pydantic-settings==2.1.0
|
pydantic-settings==2.1.0
|
||||||
python-multipart==0.0.6
|
python-multipart==0.0.22
|
||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
python-telegram-bot==20.7
|
python-telegram-bot==20.7
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
@@ -15,3 +15,7 @@ isort==5.13.2
|
|||||||
flake8==6.1.0
|
flake8==6.1.0
|
||||||
mypy==1.7.1
|
mypy==1.7.1
|
||||||
httpx==0.25.2
|
httpx==0.25.2
|
||||||
|
nicegui==3.9.0
|
||||||
|
aiohttp>=3.9.0
|
||||||
|
pytest-asyncio>=0.23.0
|
||||||
|
alembic>=1.14.0
|
||||||
17
ai_software_factory/start.sh
Normal file
17
ai_software_factory/start.sh
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# use path of this example as working directory; enables starting this script from anywhere
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
if [ "$1" = "prod" ]; then
|
||||||
|
echo "Starting Uvicorn server in production mode..."
|
||||||
|
# we also use a single worker in production mode so socket.io connections are always handled by the same worker
|
||||||
|
uvicorn main:app --workers 1 --log-level info --port 80
|
||||||
|
elif [ "$1" = "dev" ]; then
|
||||||
|
echo "Starting Uvicorn server in development mode..."
|
||||||
|
# reload implies workers = 1
|
||||||
|
uvicorn main:app --reload --log-level debug --port 8000
|
||||||
|
else
|
||||||
|
echo "Invalid parameter. Use 'prod' or 'dev'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# test-project
|
|
||||||
|
|
||||||
Test project description
|
|
||||||
|
|
||||||
## Features
|
|
||||||
- feature-1
|
|
||||||
- feature-2
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
- python
|
|
||||||
- fastapi
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# Generated by AI Software Factory
|
|
||||||
print('Hello, World!')
|
|
||||||
Reference in New Issue
Block a user