Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 032139c14f | |||
| 194d5658a6 | |||
| b9faac8d16 | |||
| 80d7716e65 | |||
| 321bf74aef | |||
| 55ee75106c | |||
| b2829caa02 | |||
| d4b280cf75 | |||
| 806db8537b | |||
| 360ed5c6f3 | |||
| 4b9eb2f359 | |||
| ebfcfb969a | |||
| 56b05eb686 | |||
| 59a7e9787e |
62
HISTORY.md
62
HISTORY.md
@@ -4,6 +4,68 @@ Changelog
|
|||||||
|
|
||||||
(unreleased)
|
(unreleased)
|
||||||
------------
|
------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- Better n8n workflow, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
|
||||||
|
0.6.4 (2026-04-10)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- Add Telegram helper functions, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
|
||||||
|
0.6.3 (2026-04-10)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- N8n workflow generation, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
|
||||||
|
0.6.2 (2026-04-10)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- Fix Quasar layout issues, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
|
||||||
|
0.6.1 (2026-04-10)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- Fix commit for version push, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
- Chore: add more health info for n8n, refs NOISSUE. [Simon
|
||||||
|
Diesenreiter]
|
||||||
|
|
||||||
|
|
||||||
|
0.6.0 (2026-04-10)
|
||||||
|
------------------
|
||||||
|
- Feat(api): expose database target in health refs NOISSUE. [Simon
|
||||||
|
Diesenreiter]
|
||||||
|
- Fix(db): prefer postgres config in production refs NOISSUE. [Simon
|
||||||
|
Diesenreiter]
|
||||||
|
|
||||||
|
|
||||||
|
0.5.0 (2026-04-10)
|
||||||
|
------------------
|
||||||
- Feat(dashboard): expose repository urls refs NOISSUE. [Simon
|
- Feat(dashboard): expose repository urls refs NOISSUE. [Simon
|
||||||
Diesenreiter]
|
Diesenreiter]
|
||||||
- Feat(factory): serve dashboard at root and create project repos refs
|
- Feat(factory): serve dashboard at root and create project repos refs
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -54,6 +54,15 @@ GITEA_OWNER=ai-software-factory
|
|||||||
# Optional legacy fixed-repository mode. Leave empty to create one repo per project.
|
# Optional legacy fixed-repository mode. Leave empty to create one repo per project.
|
||||||
GITEA_REPO=
|
GITEA_REPO=
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# In production, provide PostgreSQL settings. They take precedence over the SQLite default.
|
||||||
|
# Setting USE_SQLITE=false is still supported if you want to make the choice explicit.
|
||||||
|
POSTGRES_HOST=postgres.yourserver.com
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_USER=ai_software_factory
|
||||||
|
POSTGRES_PASSWORD=change-me
|
||||||
|
POSTGRES_DB=ai_software_factory
|
||||||
|
|
||||||
# n8n
|
# n8n
|
||||||
N8N_WEBHOOK_URL=http://n8n.yourserver.com/webhook/telegram
|
N8N_WEBHOOK_URL=http://n8n.yourserver.com/webhook/telegram
|
||||||
|
|
||||||
@@ -77,11 +86,14 @@ docker-compose up -d
|
|||||||
1. **Send a request via Telegram:**
|
1. **Send a request via Telegram:**
|
||||||
|
|
||||||
```
|
```
|
||||||
Name: My Awesome App
|
Build an internal task management app for our operations team.
|
||||||
Description: A web application for managing tasks
|
It should support user authentication, task CRUD, notifications, and reporting.
|
||||||
Features: user authentication, task CRUD, notifications
|
Prefer FastAPI with PostgreSQL and a simple web dashboard.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The backend now interprets free-form Telegram text with Ollama before generation.
|
||||||
|
If `TELEGRAM_CHAT_ID` is set, the Telegram-trigger workflow only reacts to messages from that specific chat.
|
||||||
|
|
||||||
2. **Monitor progress via Web UI:**
|
2. **Monitor progress via Web UI:**
|
||||||
|
|
||||||
Open `http://yourserver:8000/` to see the dashboard and `http://yourserver:8000/api` for API metadata
|
Open `http://yourserver:8000/` to see the dashboard and `http://yourserver:8000/api` for API metadata
|
||||||
@@ -90,6 +102,8 @@ docker-compose up -d
|
|||||||
|
|
||||||
Check your gitea repository for generated PRs
|
Check your gitea repository for generated PRs
|
||||||
|
|
||||||
|
If you deploy the container with PostgreSQL environment variables set, the service now selects PostgreSQL automatically even though SQLite remains the default for local/test usage.
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
| Endpoint | Method | Description |
|
||||||
@@ -98,6 +112,7 @@ docker-compose up -d
|
|||||||
| `/api` | GET | API information |
|
| `/api` | GET | API information |
|
||||||
| `/health` | GET | Health check |
|
| `/health` | GET | Health check |
|
||||||
| `/generate` | POST | Generate new software |
|
| `/generate` | POST | Generate new software |
|
||||||
|
| `/generate/text` | POST | Interpret free-form text and generate software |
|
||||||
| `/status/{project_id}` | GET | Get project status |
|
| `/status/{project_id}` | GET | Get project status |
|
||||||
| `/projects` | GET | List all projects |
|
| `/projects` | GET | List all projects |
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ TELEGRAM_BOT_TOKEN=your_telegram_bot_token
|
|||||||
TELEGRAM_CHAT_ID=your_chat_id
|
TELEGRAM_CHAT_ID=your_chat_id
|
||||||
|
|
||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
|
# In production, provide PostgreSQL settings below. They now take precedence over the SQLite default.
|
||||||
|
# You can also set USE_SQLITE=false explicitly if you want the intent to be obvious.
|
||||||
POSTGRES_HOST=postgres
|
POSTGRES_HOST=postgres
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
POSTGRES_USER=ai_test
|
POSTGRES_USER=ai_test
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.5.0
|
0.6.5
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""n8n setup agent for automatic webhook configuration."""
|
"""n8n setup agent for automatic webhook configuration."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from urllib import error as urllib_error
|
||||||
|
from urllib import request as urllib_request
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -46,6 +48,113 @@ class N8NSetupAgent:
|
|||||||
headers["X-N8N-API-KEY"] = self.webhook_token
|
headers["X-N8N-API-KEY"] = self.webhook_token
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
|
def _extract_message(self, payload: object) -> str:
|
||||||
|
"""Extract a useful message from an n8n response payload."""
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
for key in ("message", "error", "reason", "hint", "text"):
|
||||||
|
value = payload.get(key)
|
||||||
|
if value:
|
||||||
|
return str(value)
|
||||||
|
if payload:
|
||||||
|
return json.dumps(payload)
|
||||||
|
if payload is None:
|
||||||
|
return "No response body"
|
||||||
|
return str(payload)
|
||||||
|
|
||||||
|
def _normalize_success(self, method: str, url: str, status_code: int, payload: object) -> dict:
|
||||||
|
"""Normalize a successful n8n API response."""
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
response = dict(payload)
|
||||||
|
response.setdefault("status_code", status_code)
|
||||||
|
response.setdefault("url", url)
|
||||||
|
response.setdefault("method", method)
|
||||||
|
return response
|
||||||
|
return {"data": payload, "status_code": status_code, "url": url, "method": method}
|
||||||
|
|
||||||
|
def _normalize_error(self, method: str, url: str, status_code: int | None, payload: object) -> dict:
|
||||||
|
"""Normalize an error response with enough detail for diagnostics."""
|
||||||
|
message = self._extract_message(payload)
|
||||||
|
prefix = f"{method} {url}"
|
||||||
|
if status_code is not None:
|
||||||
|
return {
|
||||||
|
"error": f"{prefix} returned {status_code}: {message}",
|
||||||
|
"message": message,
|
||||||
|
"status_code": status_code,
|
||||||
|
"url": url,
|
||||||
|
"method": method,
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"error": f"{prefix} failed: {message}",
|
||||||
|
"message": message,
|
||||||
|
"status_code": None,
|
||||||
|
"url": url,
|
||||||
|
"method": method,
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _health_check_row(self, name: str, result: dict) -> dict:
|
||||||
|
"""Convert a raw request result into a UI/API-friendly health check row."""
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"ok": not bool(result.get("error")),
|
||||||
|
"url": result.get("url"),
|
||||||
|
"method": result.get("method", "GET"),
|
||||||
|
"status_code": result.get("status_code"),
|
||||||
|
"message": result.get("message") or ("ok" if not result.get("error") else result.get("error")),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _health_suggestion(self, checks: list[dict]) -> str | None:
|
||||||
|
"""Return a suggestion based on failed n8n health checks."""
|
||||||
|
status_codes = {check.get("status_code") for check in checks if check.get("status_code") is not None}
|
||||||
|
if status_codes and status_codes.issubset({404}):
|
||||||
|
return "Verify N8N_API_URL points to the base n8n URL, for example http://host:5678, not /api/v1 or a webhook URL."
|
||||||
|
if status_codes & {401, 403}:
|
||||||
|
return "Check the configured n8n API key or authentication method."
|
||||||
|
return "Verify the n8n URL, API key, and that the n8n API is reachable from this container."
|
||||||
|
|
||||||
|
def _build_health_result(self, healthz_result: dict, workflows_result: dict) -> dict:
|
||||||
|
"""Build a consolidated health result from the performed checks."""
|
||||||
|
checks = [
|
||||||
|
self._health_check_row("healthz", healthz_result),
|
||||||
|
self._health_check_row("workflows", workflows_result),
|
||||||
|
]
|
||||||
|
|
||||||
|
if not healthz_result.get("error"):
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": "n8n is reachable via /healthz.",
|
||||||
|
"api_url": self.api_url,
|
||||||
|
"auth_configured": bool(self.webhook_token),
|
||||||
|
"checked_via": "healthz",
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not workflows_result.get("error"):
|
||||||
|
workflows = workflows_result.get("data")
|
||||||
|
workflow_count = len(workflows) if isinstance(workflows, list) else None
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": "n8n is reachable via the workflows API, but /healthz is unavailable.",
|
||||||
|
"api_url": self.api_url,
|
||||||
|
"auth_configured": bool(self.webhook_token),
|
||||||
|
"checked_via": "workflows",
|
||||||
|
"workflow_count": workflow_count,
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestion = self._health_suggestion(checks)
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "n8n health checks failed",
|
||||||
|
"message": "n8n health checks failed.",
|
||||||
|
"api_url": self.api_url,
|
||||||
|
"auth_configured": bool(self.webhook_token),
|
||||||
|
"checked_via": "none",
|
||||||
|
"checks": checks,
|
||||||
|
"suggestion": suggestion,
|
||||||
|
}
|
||||||
|
|
||||||
async def _request(self, method: str, path: str, **kwargs) -> dict:
|
async def _request(self, method: str, path: str, **kwargs) -> dict:
|
||||||
"""Send a request to n8n and normalize the response."""
|
"""Send a request to n8n and normalize the response."""
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -62,15 +171,42 @@ class N8NSetupAgent:
|
|||||||
payload = {"text": await resp.text()}
|
payload = {"text": await resp.text()}
|
||||||
|
|
||||||
if 200 <= resp.status < 300:
|
if 200 <= resp.status < 300:
|
||||||
if isinstance(payload, dict):
|
return self._normalize_success(method, url, resp.status, payload)
|
||||||
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 self._normalize_error(method, url, resp.status, payload)
|
||||||
return {"error": f"Status {resp.status}: {message}", "status_code": resp.status, "payload": payload}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": str(e)}
|
return self._normalize_error(method, url, None, {"message": str(e)})
|
||||||
|
|
||||||
|
def _request_sync(self, method: str, path: str, **kwargs) -> dict:
|
||||||
|
"""Send a synchronous request to n8n for dashboard health snapshots."""
|
||||||
|
headers = kwargs.pop("headers", None) or self.get_auth_headers()
|
||||||
|
payload = kwargs.pop("json", None)
|
||||||
|
timeout = kwargs.pop("timeout", 5)
|
||||||
|
url = self._api_path(path)
|
||||||
|
data = None
|
||||||
|
if payload is not None:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib_request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib_request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
raw_body = resp.read().decode("utf-8")
|
||||||
|
content_type = resp.headers.get("Content-Type", "")
|
||||||
|
if "application/json" in content_type and raw_body:
|
||||||
|
parsed = json.loads(raw_body)
|
||||||
|
elif raw_body:
|
||||||
|
parsed = {"text": raw_body}
|
||||||
|
else:
|
||||||
|
parsed = {}
|
||||||
|
return self._normalize_success(method, url, resp.status, parsed)
|
||||||
|
except urllib_error.HTTPError as exc:
|
||||||
|
raw_body = exc.read().decode("utf-8") if exc.fp else ""
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw_body) if raw_body else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
parsed = {"text": raw_body} if raw_body else {}
|
||||||
|
return self._normalize_error(method, url, exc.code, parsed)
|
||||||
|
except Exception as exc:
|
||||||
|
return self._normalize_error(method, url, None, {"message": str(exc)})
|
||||||
|
|
||||||
async def get_workflow(self, workflow_name: str) -> Optional[dict]:
|
async def get_workflow(self, workflow_name: str) -> Optional[dict]:
|
||||||
"""Get a workflow by name."""
|
"""Get a workflow by name."""
|
||||||
@@ -84,11 +220,31 @@ class N8NSetupAgent:
|
|||||||
|
|
||||||
async def create_workflow(self, workflow_json: dict) -> dict:
|
async def create_workflow(self, workflow_json: dict) -> dict:
|
||||||
"""Create or update a workflow."""
|
"""Create or update a workflow."""
|
||||||
return await self._request("POST", "workflows", json=workflow_json)
|
return await self._request("POST", "workflows", json=self._workflow_payload(workflow_json))
|
||||||
|
|
||||||
|
def _workflow_payload(self, workflow_json: dict) -> dict:
|
||||||
|
"""Return a workflow payload without server-managed read-only fields."""
|
||||||
|
payload = dict(workflow_json)
|
||||||
|
payload.pop("active", None)
|
||||||
|
payload.pop("id", None)
|
||||||
|
payload.pop("createdAt", None)
|
||||||
|
payload.pop("updatedAt", None)
|
||||||
|
payload.pop("versionId", None)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
async def _update_workflow_via_put(self, workflow_id: str, workflow_json: dict) -> dict:
|
||||||
|
"""Fallback update path for n8n instances that only support PUT."""
|
||||||
|
return await self._request("PUT", f"workflows/{workflow_id}", json=self._workflow_payload(workflow_json))
|
||||||
|
|
||||||
async def update_workflow(self, workflow_id: str, workflow_json: dict) -> dict:
|
async def update_workflow(self, workflow_id: str, workflow_json: dict) -> dict:
|
||||||
"""Update an existing workflow."""
|
"""Update an existing workflow."""
|
||||||
return await self._request("PATCH", f"workflows/{workflow_id}", json=workflow_json)
|
result = await self._request("PATCH", f"workflows/{workflow_id}", json=self._workflow_payload(workflow_json))
|
||||||
|
if result.get("status_code") == 405:
|
||||||
|
fallback = await self._update_workflow_via_put(workflow_id, workflow_json)
|
||||||
|
if not fallback.get("error") and isinstance(fallback, dict):
|
||||||
|
fallback.setdefault("method", "PUT")
|
||||||
|
return fallback
|
||||||
|
return result
|
||||||
|
|
||||||
async def enable_workflow(self, workflow_id: str) -> dict:
|
async def enable_workflow(self, workflow_id: str) -> dict:
|
||||||
"""Enable a workflow."""
|
"""Enable a workflow."""
|
||||||
@@ -96,6 +252,11 @@ class N8NSetupAgent:
|
|||||||
if result.get("error"):
|
if result.get("error"):
|
||||||
fallback = await self._request("PATCH", f"workflows/{workflow_id}", json={"active": True})
|
fallback = await self._request("PATCH", f"workflows/{workflow_id}", json={"active": True})
|
||||||
if fallback.get("error"):
|
if fallback.get("error"):
|
||||||
|
if fallback.get("status_code") == 405:
|
||||||
|
put_fallback = await self._request("PUT", f"workflows/{workflow_id}", json={"active": True})
|
||||||
|
if put_fallback.get("error"):
|
||||||
|
return put_fallback
|
||||||
|
return {"success": True, "id": workflow_id, "method": "put"}
|
||||||
return fallback
|
return fallback
|
||||||
return {"success": True, "id": workflow_id, "method": "patch"}
|
return {"success": True, "id": workflow_id, "method": "patch"}
|
||||||
return {"success": True, "id": workflow_id, "method": "activate"}
|
return {"success": True, "id": workflow_id, "method": "activate"}
|
||||||
@@ -114,12 +275,12 @@ class N8NSetupAgent:
|
|||||||
return value
|
return value
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def build_telegram_workflow(self, webhook_path: str, backend_url: str) -> dict:
|
def build_telegram_workflow(self, webhook_path: str, backend_url: str, allowed_chat_id: str | None = None) -> dict:
|
||||||
"""Build the Telegram-to-backend workflow definition."""
|
"""Build the Telegram-to-backend workflow definition."""
|
||||||
normalized_path = webhook_path.strip().strip("/") or "telegram"
|
normalized_path = webhook_path.strip().strip("/") or "telegram"
|
||||||
|
allowed_chat = json.dumps(str(allowed_chat_id)) if allowed_chat_id else "''"
|
||||||
return {
|
return {
|
||||||
"name": "Telegram to AI Software Factory",
|
"name": "Telegram to AI Software Factory",
|
||||||
"active": False,
|
|
||||||
"settings": {"executionOrder": "v1"},
|
"settings": {"executionOrder": "v1"},
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
@@ -137,13 +298,13 @@ class N8NSetupAgent:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "parse-node",
|
"id": "parse-node",
|
||||||
"name": "Prepare Software Request",
|
"name": "Prepare Freeform Request",
|
||||||
"type": "n8n-nodes-base.code",
|
"type": "n8n-nodes-base.code",
|
||||||
"typeVersion": 2,
|
"typeVersion": 2,
|
||||||
"position": [-200, 120],
|
"position": [-200, 120],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"language": "javaScript",
|
"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 } } }];",
|
"jsCode": f"const allowedChatId = {allowed_chat};\nconst body = $json.body ?? $json;\nconst message = body.message ?? body;\nconst text = String(message.text ?? '').trim();\nconst chatId = String(message.chat?.id ?? '');\nif (allowedChatId && chatId !== allowedChatId) {{\n return [{{ json: {{ ignored: true, message: `Ignoring message from chat ${{chatId}}`, prompt_text: text, source: 'telegram', chat_id: chatId, chat_type: message.chat?.type ?? null }} }}];\n}}\nreturn [{{ json: {{ prompt_text: text, source: 'telegram', chat_id: chatId, chat_type: message.chat?.type ?? null }} }}];",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -174,8 +335,8 @@ class N8NSetupAgent:
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
"connections": {
|
"connections": {
|
||||||
"Telegram Webhook": {"main": [[{"node": "Prepare Software Request", "type": "main", "index": 0}]]},
|
"Telegram Webhook": {"main": [[{"node": "Prepare Freeform Request", "type": "main", "index": 0}]]},
|
||||||
"Prepare Software Request": {"main": [[{"node": "AI Software Factory API", "type": "main", "index": 0}]]},
|
"Prepare Freeform 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}]]},
|
"AI Software Factory API": {"main": [[{"node": "Respond to Telegram Webhook", "type": "main", "index": 0}]]},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -184,11 +345,12 @@ class N8NSetupAgent:
|
|||||||
self,
|
self,
|
||||||
backend_url: str,
|
backend_url: str,
|
||||||
credential_name: str,
|
credential_name: str,
|
||||||
|
allowed_chat_id: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Build a production Telegram Trigger based workflow."""
|
"""Build a production Telegram Trigger based workflow."""
|
||||||
|
allowed_chat = json.dumps(str(allowed_chat_id)) if allowed_chat_id else "''"
|
||||||
return {
|
return {
|
||||||
"name": "Telegram to AI Software Factory",
|
"name": "Telegram to AI Software Factory",
|
||||||
"active": False,
|
|
||||||
"settings": {"executionOrder": "v1"},
|
"settings": {"executionOrder": "v1"},
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
@@ -201,14 +363,14 @@ class N8NSetupAgent:
|
|||||||
"credentials": {"telegramApi": {"name": credential_name}},
|
"credentials": {"telegramApi": {"name": credential_name}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "parse-node",
|
"id": "filter-node",
|
||||||
"name": "Prepare Software Request",
|
"name": "Prepare Freeform Request",
|
||||||
"type": "n8n-nodes-base.code",
|
"type": "n8n-nodes-base.code",
|
||||||
"typeVersion": 2,
|
"typeVersion": 2,
|
||||||
"position": [-180, 120],
|
"position": [-180, 120],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"language": "javaScript",
|
"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 }];",
|
"jsCode": f"const allowedChatId = {allowed_chat};\nconst message = $json.message ?? $json;\nconst text = String(message.text ?? '').trim();\nconst chatId = String(message.chat?.id ?? '');\nif (!text) return [];\nif (allowedChatId && chatId !== allowedChatId) return [];\nreturn [{{ json: {{ prompt_text: text, source: 'telegram', chat_id: chatId, chat_type: message.chat?.type ?? null }} }}];",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -242,8 +404,8 @@ class N8NSetupAgent:
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
"connections": {
|
"connections": {
|
||||||
"Telegram Trigger": {"main": [[{"node": "Prepare Software Request", "type": "main", "index": 0}]]},
|
"Telegram Trigger": {"main": [[{"node": "Prepare Freeform Request", "type": "main", "index": 0}]]},
|
||||||
"Prepare Software Request": {"main": [[{"node": "AI Software Factory API", "type": "main", "index": 0}]]},
|
"Prepare Freeform Request": {"main": [[{"node": "AI Software Factory API", "type": "main", "index": 0}]]},
|
||||||
"AI Software Factory API": {"main": [[{"node": "Send Telegram Update", "type": "main", "index": 0}]]},
|
"AI Software Factory API": {"main": [[{"node": "Send Telegram Update", "type": "main", "index": 0}]]},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -297,19 +459,21 @@ class N8NSetupAgent:
|
|||||||
"""
|
"""
|
||||||
return await self.setup(
|
return await self.setup(
|
||||||
webhook_path=webhook_path,
|
webhook_path=webhook_path,
|
||||||
backend_url=f"{settings.backend_public_url}/generate",
|
backend_url=f"{settings.backend_public_url}/generate/text",
|
||||||
force_update=False,
|
force_update=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def health_check(self) -> dict:
|
async def health_check(self) -> dict:
|
||||||
"""Check n8n API health."""
|
"""Check n8n API health."""
|
||||||
result = await self._request("GET", f"{self.api_url}/healthz")
|
result = await self._request("GET", f"{self.api_url}/healthz")
|
||||||
if result.get("error"):
|
fallback = await self._request("GET", "workflows")
|
||||||
fallback = await self._request("GET", "workflows")
|
return self._build_health_result(result, fallback)
|
||||||
if fallback.get("error"):
|
|
||||||
return fallback
|
def health_check_sync(self) -> dict:
|
||||||
return {"status": "ok", "checked_via": "workflows"}
|
"""Synchronously check n8n API health for UI rendering."""
|
||||||
return {"status": "ok", "checked_via": "healthz"}
|
result = self._request_sync("GET", f"{self.api_url}/healthz")
|
||||||
|
fallback = self._request_sync("GET", "workflows")
|
||||||
|
return self._build_health_result(result, fallback)
|
||||||
|
|
||||||
async def setup(
|
async def setup(
|
||||||
self,
|
self,
|
||||||
@@ -324,9 +488,15 @@ class N8NSetupAgent:
|
|||||||
# First, verify n8n is accessible
|
# First, verify n8n is accessible
|
||||||
health = await self.health_check()
|
health = await self.health_check()
|
||||||
if health.get("error"):
|
if health.get("error"):
|
||||||
return {"status": "error", "message": health.get("error")}
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": health.get("message") or health.get("error"),
|
||||||
|
"health": health,
|
||||||
|
"checks": health.get("checks", []),
|
||||||
|
"suggestion": health.get("suggestion"),
|
||||||
|
}
|
||||||
|
|
||||||
effective_backend_url = backend_url or f"{settings.backend_public_url}/generate"
|
effective_backend_url = backend_url or f"{settings.backend_public_url}/generate/text"
|
||||||
effective_bot_token = telegram_bot_token or settings.telegram_bot_token
|
effective_bot_token = telegram_bot_token or settings.telegram_bot_token
|
||||||
effective_credential_name = telegram_credential_name or settings.n8n_telegram_credential_name
|
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)
|
trigger_mode = use_telegram_trigger if use_telegram_trigger is not None else bool(effective_bot_token)
|
||||||
@@ -334,20 +504,22 @@ class N8NSetupAgent:
|
|||||||
if trigger_mode:
|
if trigger_mode:
|
||||||
credential = await self.ensure_telegram_credential(effective_bot_token, effective_credential_name)
|
credential = await self.ensure_telegram_credential(effective_bot_token, effective_credential_name)
|
||||||
if credential.get("error"):
|
if credential.get("error"):
|
||||||
return {"status": "error", "message": credential["error"]}
|
return {"status": "error", "message": credential["error"], "details": credential}
|
||||||
workflow = self.build_telegram_trigger_workflow(
|
workflow = self.build_telegram_trigger_workflow(
|
||||||
backend_url=effective_backend_url,
|
backend_url=effective_backend_url,
|
||||||
credential_name=effective_credential_name,
|
credential_name=effective_credential_name,
|
||||||
|
allowed_chat_id=settings.telegram_chat_id,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
workflow = self.build_telegram_workflow(
|
workflow = self.build_telegram_workflow(
|
||||||
webhook_path=webhook_path,
|
webhook_path=webhook_path,
|
||||||
backend_url=effective_backend_url,
|
backend_url=effective_backend_url,
|
||||||
|
allowed_chat_id=settings.telegram_chat_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
existing = await self.get_workflow(workflow["name"])
|
existing = await self.get_workflow(workflow["name"])
|
||||||
if isinstance(existing, dict) and existing.get("error"):
|
if isinstance(existing, dict) and existing.get("error"):
|
||||||
return {"status": "error", "message": existing["error"]}
|
return {"status": "error", "message": existing["error"], "details": existing}
|
||||||
|
|
||||||
workflow_id = None
|
workflow_id = None
|
||||||
if existing and existing.get("id"):
|
if existing and existing.get("id"):
|
||||||
@@ -361,12 +533,12 @@ class N8NSetupAgent:
|
|||||||
workflow_id = str(result.get("id", "")) if isinstance(result, dict) else None
|
workflow_id = str(result.get("id", "")) if isinstance(result, dict) else None
|
||||||
|
|
||||||
if isinstance(result, dict) and result.get("error"):
|
if isinstance(result, dict) and result.get("error"):
|
||||||
return {"status": "error", "message": result["error"]}
|
return {"status": "error", "message": result["error"], "details": result}
|
||||||
|
|
||||||
workflow_id = workflow_id or str(result.get("id", ""))
|
workflow_id = workflow_id or str(result.get("id", ""))
|
||||||
enable_result = await self.enable_workflow(workflow_id)
|
enable_result = await self.enable_workflow(workflow_id)
|
||||||
if enable_result.get("error"):
|
if enable_result.get("error"):
|
||||||
return {"status": "error", "message": enable_result["error"], "workflow": result}
|
return {"status": "error", "message": enable_result["error"], "workflow": result, "details": enable_result}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
|
|||||||
105
ai_software_factory/agents/request_interpreter.py
Normal file
105
ai_software_factory/agents/request_interpreter.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""Interpret free-form software requests into structured generation input."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ..config import settings
|
||||||
|
except ImportError:
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class RequestInterpreter:
|
||||||
|
"""Use Ollama to turn free-form text into a structured software request."""
|
||||||
|
|
||||||
|
def __init__(self, ollama_url: str | None = None, model: str | None = None):
|
||||||
|
self.ollama_url = (ollama_url or settings.ollama_url).rstrip('/')
|
||||||
|
self.model = model or settings.OLLAMA_MODEL
|
||||||
|
|
||||||
|
async def interpret(self, prompt_text: str) -> dict:
|
||||||
|
"""Interpret free-form text into the request shape expected by the orchestrator."""
|
||||||
|
normalized = prompt_text.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError('Prompt text cannot be empty')
|
||||||
|
|
||||||
|
try:
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(
|
||||||
|
f'{self.ollama_url}/api/chat',
|
||||||
|
json={
|
||||||
|
'model': self.model,
|
||||||
|
'stream': False,
|
||||||
|
'format': 'json',
|
||||||
|
'messages': [
|
||||||
|
{
|
||||||
|
'role': 'system',
|
||||||
|
'content': (
|
||||||
|
'You extract structured software requests. '
|
||||||
|
'Return only JSON with keys name, description, features, tech_stack. '
|
||||||
|
'name and description must be concise strings. '
|
||||||
|
'features and tech_stack must be arrays of strings. '
|
||||||
|
'Infer missing details from the user request instead of leaving arrays empty when possible.'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{'role': 'user', 'content': normalized},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
) as resp:
|
||||||
|
payload = await resp.json()
|
||||||
|
if 200 <= resp.status < 300:
|
||||||
|
content = payload.get('message', {}).get('content', '')
|
||||||
|
if content:
|
||||||
|
return self._normalize_interpreted_request(json.loads(content), normalized)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return self._heuristic_fallback(normalized)
|
||||||
|
|
||||||
|
def _normalize_interpreted_request(self, interpreted: dict, original_prompt: str) -> dict:
|
||||||
|
"""Normalize LLM output into the required request shape."""
|
||||||
|
name = str(interpreted.get('name') or '').strip() or self._derive_name(original_prompt)
|
||||||
|
description = str(interpreted.get('description') or '').strip() or original_prompt[:255]
|
||||||
|
features = self._normalize_list(interpreted.get('features'))
|
||||||
|
tech_stack = self._normalize_list(interpreted.get('tech_stack'))
|
||||||
|
if not features:
|
||||||
|
features = ['core workflow based on free-form request']
|
||||||
|
return {
|
||||||
|
'name': name[:255],
|
||||||
|
'description': description[:255],
|
||||||
|
'features': features,
|
||||||
|
'tech_stack': tech_stack,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _normalize_list(self, value) -> list[str]:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [str(item).strip() for item in value if str(item).strip()]
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return [item.strip() for item in value.split(',') if item.strip()]
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _derive_name(self, prompt_text: str) -> str:
|
||||||
|
"""Derive a stable project name when the LLM does not provide one."""
|
||||||
|
first_line = prompt_text.splitlines()[0].strip()
|
||||||
|
cleaned = re.sub(r'[^A-Za-z0-9 ]+', ' ', first_line)
|
||||||
|
words = [word.capitalize() for word in cleaned.split()[:4]]
|
||||||
|
return ' '.join(words) or 'Generated Project'
|
||||||
|
|
||||||
|
def _heuristic_fallback(self, prompt_text: str) -> dict:
|
||||||
|
"""Fallback request extraction when Ollama is unavailable."""
|
||||||
|
lowered = prompt_text.lower()
|
||||||
|
tech_candidates = [
|
||||||
|
'python', 'fastapi', 'django', 'flask', 'postgresql', 'sqlite', 'react', 'vue', 'nicegui', 'docker'
|
||||||
|
]
|
||||||
|
tech_stack = [candidate for candidate in tech_candidates if candidate in lowered]
|
||||||
|
sentences = [part.strip() for part in re.split(r'[\n\.]+', prompt_text) if part.strip()]
|
||||||
|
features = sentences[:3] or ['Implement the user request from free-form text']
|
||||||
|
return {
|
||||||
|
'name': self._derive_name(prompt_text),
|
||||||
|
'description': sentences[0][:255] if sentences else prompt_text[:255],
|
||||||
|
'features': features,
|
||||||
|
'tech_stack': tech_stack,
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
"""Telegram bot integration for n8n webhook."""
|
"""Telegram bot integration for n8n webhook."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
@@ -12,6 +10,59 @@ class TelegramHandler:
|
|||||||
def __init__(self, webhook_url: str):
|
def __init__(self, webhook_url: str):
|
||||||
self.webhook_url = webhook_url
|
self.webhook_url = webhook_url
|
||||||
self.api_url = "https://api.telegram.org/bot"
|
self.api_url = "https://api.telegram.org/bot"
|
||||||
|
|
||||||
|
def build_prompt_guide_message(self, backend_url: str | None = None) -> str:
|
||||||
|
"""Build a Telegram message explaining the expected prompt format."""
|
||||||
|
lines = [
|
||||||
|
"AI Software Factory is listening in this chat.",
|
||||||
|
"",
|
||||||
|
"You can send free-form software requests in normal language.",
|
||||||
|
"",
|
||||||
|
"Example:",
|
||||||
|
"Build an internal inventory portal for our warehouse team.",
|
||||||
|
"It should support role-based login, stock dashboards, and purchase orders.",
|
||||||
|
"Prefer FastAPI, PostgreSQL, and a simple web UI.",
|
||||||
|
"",
|
||||||
|
"The backend will interpret the request and turn it into a structured project plan.",
|
||||||
|
]
|
||||||
|
if backend_url:
|
||||||
|
lines.extend(["", f"Backend target: {backend_url}"])
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
async def send_message(self, bot_token: str, chat_id: str | int, text: str) -> dict:
|
||||||
|
"""Send a direct Telegram message using the configured bot."""
|
||||||
|
if not bot_token:
|
||||||
|
return {"status": "error", "message": "Telegram bot token is not configured"}
|
||||||
|
if chat_id in (None, ""):
|
||||||
|
return {"status": "error", "message": "Telegram chat id is not configured"}
|
||||||
|
|
||||||
|
api_endpoint = f"{self.api_url}{bot_token}/sendMessage"
|
||||||
|
|
||||||
|
try:
|
||||||
|
import aiohttp
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(
|
||||||
|
api_endpoint,
|
||||||
|
json={
|
||||||
|
"chat_id": str(chat_id),
|
||||||
|
"text": text,
|
||||||
|
},
|
||||||
|
) as resp:
|
||||||
|
payload = await resp.json()
|
||||||
|
if 200 <= resp.status < 300 and payload.get("ok"):
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Telegram prompt guide sent successfully",
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
description = payload.get("description") or payload.get("message") or str(payload)
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Telegram API returned {resp.status}: {description}",
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"status": "error", "message": str(exc)}
|
||||||
|
|
||||||
async def handle_message(self, message_data: dict) -> dict:
|
async def handle_message(self, message_data: dict) -> dict:
|
||||||
"""Handle incoming Telegram message."""
|
"""Handle incoming Telegram message."""
|
||||||
|
|||||||
@@ -66,6 +66,32 @@ class Settings(BaseSettings):
|
|||||||
DB_POOL_RECYCLE: int = 3600
|
DB_POOL_RECYCLE: int = 3600
|
||||||
DB_POOL_TIMEOUT: int = 30
|
DB_POOL_TIMEOUT: int = 30
|
||||||
|
|
||||||
|
@property
|
||||||
|
def postgres_url(self) -> str:
|
||||||
|
"""Get PostgreSQL URL with trimmed whitespace."""
|
||||||
|
return (self.POSTGRES_URL or "").strip()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def postgres_env_configured(self) -> bool:
|
||||||
|
"""Whether PostgreSQL was explicitly configured via environment variables."""
|
||||||
|
if self.postgres_url:
|
||||||
|
return True
|
||||||
|
postgres_env_keys = (
|
||||||
|
"POSTGRES_HOST",
|
||||||
|
"POSTGRES_PORT",
|
||||||
|
"POSTGRES_USER",
|
||||||
|
"POSTGRES_PASSWORD",
|
||||||
|
"POSTGRES_DB",
|
||||||
|
)
|
||||||
|
return any(bool(os.environ.get(key, "").strip()) for key in postgres_env_keys)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def use_sqlite(self) -> bool:
|
||||||
|
"""Whether SQLite should be used as the active database backend."""
|
||||||
|
if not self.USE_SQLITE:
|
||||||
|
return False
|
||||||
|
return not self.postgres_env_configured
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pool(self) -> dict:
|
def pool(self) -> dict:
|
||||||
"""Get database pool configuration."""
|
"""Get database pool configuration."""
|
||||||
@@ -79,8 +105,10 @@ class Settings(BaseSettings):
|
|||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
"""Get database connection URL."""
|
"""Get database connection URL."""
|
||||||
if self.USE_SQLITE:
|
if self.use_sqlite:
|
||||||
return f"sqlite:///{self.SQLITE_DB_PATH}"
|
return f"sqlite:///{self.SQLITE_DB_PATH}"
|
||||||
|
if self.postgres_url:
|
||||||
|
return self.postgres_url
|
||||||
return (
|
return (
|
||||||
f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
|
f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
|
||||||
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
||||||
@@ -89,8 +117,10 @@ class Settings(BaseSettings):
|
|||||||
@property
|
@property
|
||||||
def test_database_url(self) -> str:
|
def test_database_url(self) -> str:
|
||||||
"""Get test database connection URL."""
|
"""Get test database connection URL."""
|
||||||
if self.USE_SQLITE:
|
if self.use_sqlite:
|
||||||
return f"sqlite:///{self.SQLITE_DB_PATH}"
|
return f"sqlite:///{self.SQLITE_DB_PATH}"
|
||||||
|
if self.postgres_url:
|
||||||
|
return self.postgres_url
|
||||||
return (
|
return (
|
||||||
f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
|
f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
|
||||||
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_TEST_DB}"
|
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_TEST_DB}"
|
||||||
|
|||||||
@@ -3,19 +3,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
|
from html import escape
|
||||||
|
|
||||||
from nicegui import ui
|
from nicegui import app, ui
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .agents.database_manager import DatabaseManager
|
from .agents.database_manager import DatabaseManager
|
||||||
from .agents.n8n_setup import N8NSetupAgent
|
from .agents.n8n_setup import N8NSetupAgent
|
||||||
|
from .agents.telegram import TelegramHandler
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import get_db_sync, init_db
|
from .database import get_database_runtime_summary, get_db_sync, init_db
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from agents.database_manager import DatabaseManager
|
from agents.database_manager import DatabaseManager
|
||||||
from agents.n8n_setup import N8NSetupAgent
|
from agents.n8n_setup import N8NSetupAgent
|
||||||
|
from agents.telegram import TelegramHandler
|
||||||
from config import settings
|
from config import settings
|
||||||
from database import get_db_sync, init_db
|
from database import get_database_runtime_summary, get_db_sync, init_db
|
||||||
|
|
||||||
|
|
||||||
def _resolve_n8n_api_url() -> str:
|
def _resolve_n8n_api_url() -> str:
|
||||||
@@ -65,8 +68,32 @@ def _load_dashboard_snapshot() -> dict:
|
|||||||
return {'error': f'Database error: {exc}'}
|
return {'error': f'Database error: {exc}'}
|
||||||
|
|
||||||
|
|
||||||
def create_dashboard():
|
def _load_n8n_health_snapshot() -> dict:
|
||||||
"""Create the main NiceGUI dashboard."""
|
"""Load an n8n health snapshot for UI rendering."""
|
||||||
|
api_url = _resolve_n8n_api_url()
|
||||||
|
if not api_url:
|
||||||
|
return {
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'N8N_API_URL or N8N_WEBHOOK_URL is not configured.',
|
||||||
|
'api_url': 'Not configured',
|
||||||
|
'auth_configured': bool(settings.n8n_api_key),
|
||||||
|
'checks': [],
|
||||||
|
'suggestion': 'Set N8N_API_URL to the base n8n address before provisioning workflows.',
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
return N8NSetupAgent(api_url=api_url, webhook_token=settings.n8n_api_key).health_check_sync()
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Unable to run n8n health checks: {exc}',
|
||||||
|
'api_url': api_url,
|
||||||
|
'auth_configured': bool(settings.n8n_api_key),
|
||||||
|
'checks': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_dashboard_styles() -> None:
|
||||||
|
"""Register shared dashboard styles."""
|
||||||
ui.add_head_html(
|
ui.add_head_html(
|
||||||
"""
|
"""
|
||||||
<style>
|
<style>
|
||||||
@@ -81,6 +108,115 @@ def create_dashboard():
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_n8n_error_dialog(result: dict) -> None:
|
||||||
|
"""Render a detailed n8n failure dialog."""
|
||||||
|
health = result.get('health', {}) if isinstance(result.get('health'), dict) else {}
|
||||||
|
checks = result.get('checks') or health.get('checks') or []
|
||||||
|
details = result.get('details') if isinstance(result.get('details'), dict) else {}
|
||||||
|
|
||||||
|
with ui.dialog() as dialog, ui.card().classes('factory-panel q-pa-lg').style('max-width: 840px; width: min(92vw, 840px);'):
|
||||||
|
ui.label('n8n provisioning failed').style('font-size: 1.35rem; font-weight: 800; color: #5c2d1f;')
|
||||||
|
ui.label(result.get('message', 'No error message returned.')).classes('factory-muted')
|
||||||
|
if result.get('suggestion') or health.get('suggestion'):
|
||||||
|
ui.label(result.get('suggestion') or health.get('suggestion')).classes('factory-chip q-mt-sm')
|
||||||
|
if checks:
|
||||||
|
ui.label('Health checks').style('font-size: 1rem; font-weight: 700; color: #3a281a; margin-top: 12px;')
|
||||||
|
for check in checks:
|
||||||
|
status = 'OK' if check.get('ok') else 'FAIL'
|
||||||
|
message = check.get('message') or 'No detail available'
|
||||||
|
ui.markdown(
|
||||||
|
f"- **{escape(check.get('name', 'check'))}** · {status} · {escape(str(check.get('status_code') or 'n/a'))} · {escape(check.get('url') or 'unknown url')}"
|
||||||
|
)
|
||||||
|
ui.label(message).classes('factory-muted')
|
||||||
|
if details:
|
||||||
|
ui.label('API response').style('font-size: 1rem; font-weight: 700; color: #3a281a; margin-top: 12px;')
|
||||||
|
ui.label(str(details)).classes('factory-code')
|
||||||
|
with ui.row().classes('justify-end w-full q-mt-md'):
|
||||||
|
ui.button('Close', on_click=dialog.close).props('unelevated color=dark')
|
||||||
|
dialog.open()
|
||||||
|
|
||||||
|
|
||||||
|
def _render_health_panels() -> None:
|
||||||
|
"""Render application and n8n health panels."""
|
||||||
|
runtime = get_database_runtime_summary()
|
||||||
|
n8n_health = _load_n8n_health_snapshot()
|
||||||
|
|
||||||
|
with ui.grid(columns=2).classes('w-full gap-4'):
|
||||||
|
with ui.card().classes('factory-panel q-pa-lg'):
|
||||||
|
ui.label('Application Health').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
|
||||||
|
rows = [
|
||||||
|
('Status', 'healthy'),
|
||||||
|
('Database Backend', runtime['backend']),
|
||||||
|
('Database Target', runtime['target']),
|
||||||
|
('Database Name', runtime['database']),
|
||||||
|
('Backend URL', settings.backend_public_url),
|
||||||
|
('Projects Root', str(settings.projects_root)),
|
||||||
|
]
|
||||||
|
for label, value in rows:
|
||||||
|
with ui.row().classes('justify-between w-full q-mt-sm'):
|
||||||
|
ui.label(label).classes('factory-muted')
|
||||||
|
ui.label(str(value)).style('font-weight: 600; color: #3a281a;')
|
||||||
|
|
||||||
|
with ui.card().classes('factory-panel q-pa-lg'):
|
||||||
|
ui.label('n8n Connection Status').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
|
||||||
|
status_label = n8n_health.get('status', 'unknown').upper()
|
||||||
|
ui.label(status_label).classes('factory-chip')
|
||||||
|
ui.label(n8n_health.get('message', 'No n8n status available.')).classes('factory-muted q-mt-sm')
|
||||||
|
rows = [
|
||||||
|
('API URL', n8n_health.get('api_url') or 'Not configured'),
|
||||||
|
('Auth Configured', 'yes' if n8n_health.get('auth_configured') else 'no'),
|
||||||
|
('Checked Via', n8n_health.get('checked_via') or 'none'),
|
||||||
|
('Telegram Chat ID', settings.telegram_chat_id or 'Not configured'),
|
||||||
|
]
|
||||||
|
if n8n_health.get('workflow_count') is not None:
|
||||||
|
rows.append(('Workflow Count', str(n8n_health['workflow_count'])))
|
||||||
|
for label, value in rows:
|
||||||
|
with ui.row().classes('justify-between w-full q-mt-sm'):
|
||||||
|
ui.label(label).classes('factory-muted')
|
||||||
|
ui.label(str(value)).style('font-weight: 600; color: #3a281a;')
|
||||||
|
if n8n_health.get('suggestion'):
|
||||||
|
ui.label(n8n_health['suggestion']).classes('factory-chip q-mt-md')
|
||||||
|
checks = n8n_health.get('checks', [])
|
||||||
|
if checks:
|
||||||
|
ui.label('Checks').style('font-size: 1rem; font-weight: 700; color: #3a281a; margin-top: 12px;')
|
||||||
|
for check in checks:
|
||||||
|
status = 'OK' if check.get('ok') else 'FAIL'
|
||||||
|
ui.markdown(
|
||||||
|
f"- **{escape(check.get('name', 'check'))}** · {status} · {escape(str(check.get('status_code') or 'n/a'))} · {escape(check.get('url') or 'unknown url')}"
|
||||||
|
)
|
||||||
|
if check.get('message'):
|
||||||
|
ui.label(check['message']).classes('factory-muted')
|
||||||
|
|
||||||
|
|
||||||
|
def create_health_page() -> None:
|
||||||
|
"""Create a dedicated health page for runtime diagnostics."""
|
||||||
|
_add_dashboard_styles()
|
||||||
|
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('Factory Health').style('font-size: 2rem; font-weight: 800; color: #302116;')
|
||||||
|
ui.label('Current application and n8n connectivity diagnostics.').classes('factory-muted')
|
||||||
|
with ui.row().classes('items-center gap-2'):
|
||||||
|
ui.link('Back to Dashboard', '/')
|
||||||
|
ui.link('Refresh Health', '/health-ui')
|
||||||
|
_render_health_panels()
|
||||||
|
|
||||||
|
|
||||||
|
def create_dashboard():
|
||||||
|
"""Create the main NiceGUI dashboard."""
|
||||||
|
_add_dashboard_styles()
|
||||||
|
active_tab_key = 'dashboard.active_tab'
|
||||||
|
|
||||||
|
def _selected_tab_name() -> str:
|
||||||
|
"""Return the persisted active dashboard tab."""
|
||||||
|
return app.storage.user.get(active_tab_key, 'overview')
|
||||||
|
|
||||||
|
def _store_selected_tab(event) -> None:
|
||||||
|
"""Persist the active dashboard tab across refreshes."""
|
||||||
|
app.storage.user[active_tab_key] = event.value or 'overview'
|
||||||
|
|
||||||
async def setup_n8n_workflow_action() -> None:
|
async def setup_n8n_workflow_action() -> None:
|
||||||
api_url = _resolve_n8n_api_url()
|
api_url = _resolve_n8n_api_url()
|
||||||
if not api_url:
|
if not api_url:
|
||||||
@@ -90,7 +226,7 @@ def create_dashboard():
|
|||||||
agent = N8NSetupAgent(api_url=api_url, webhook_token=settings.n8n_api_key)
|
agent = N8NSetupAgent(api_url=api_url, webhook_token=settings.n8n_api_key)
|
||||||
result = await agent.setup(
|
result = await agent.setup(
|
||||||
webhook_path='telegram',
|
webhook_path='telegram',
|
||||||
backend_url=f'{settings.backend_public_url}/generate',
|
backend_url=f'{settings.backend_public_url}/generate/text',
|
||||||
force_update=True,
|
force_update=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -103,9 +239,39 @@ def create_dashboard():
|
|||||||
message=result.get('message', str(result)),
|
message=result.get('message', str(result)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if result.get('status') == 'error':
|
||||||
|
_render_n8n_error_dialog(result)
|
||||||
ui.notify(result.get('message', 'n8n setup finished'), color='positive' if result.get('status') == 'success' else 'negative')
|
ui.notify(result.get('message', 'n8n setup finished'), color='positive' if result.get('status') == 'success' else 'negative')
|
||||||
dashboard_body.refresh()
|
dashboard_body.refresh()
|
||||||
|
|
||||||
|
async def send_telegram_prompt_guide_action() -> None:
|
||||||
|
if not settings.telegram_bot_token:
|
||||||
|
ui.notify('Configure TELEGRAM_BOT_TOKEN first', color='negative')
|
||||||
|
return
|
||||||
|
if not settings.telegram_chat_id:
|
||||||
|
ui.notify('Configure TELEGRAM_CHAT_ID to message the prompt channel', color='negative')
|
||||||
|
return
|
||||||
|
|
||||||
|
handler = TelegramHandler(settings.n8n_webhook_url or _resolve_n8n_api_url())
|
||||||
|
message = handler.build_prompt_guide_message(settings.backend_public_url)
|
||||||
|
result = await handler.send_message(
|
||||||
|
bot_token=settings.telegram_bot_token,
|
||||||
|
chat_id=settings.telegram_chat_id,
|
||||||
|
text=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
db = get_db_sync()
|
||||||
|
if db is not None:
|
||||||
|
with closing(db):
|
||||||
|
DatabaseManager(db).log_system_event(
|
||||||
|
component='telegram',
|
||||||
|
level='INFO' if result.get('status') == 'success' else 'ERROR',
|
||||||
|
message=result.get('message', str(result)),
|
||||||
|
)
|
||||||
|
|
||||||
|
ui.notify(result.get('message', 'Telegram message sent'), color='positive' if result.get('status') == 'success' else 'negative')
|
||||||
|
dashboard_body.refresh()
|
||||||
|
|
||||||
def init_db_action() -> None:
|
def init_db_action() -> None:
|
||||||
result = init_db()
|
result = init_db()
|
||||||
ui.notify(result.get('message', 'Database initialized'), color='positive' if result.get('status') == 'success' else 'negative')
|
ui.notify(result.get('message', 'Database initialized'), color='positive' if result.get('status') == 'success' else 'negative')
|
||||||
@@ -144,6 +310,7 @@ def create_dashboard():
|
|||||||
ui.button('Refresh', on_click=dashboard_body.refresh).props('outline')
|
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('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')
|
ui.button('Provision n8n Workflow', on_click=setup_n8n_workflow_action).props('unelevated color=accent')
|
||||||
|
ui.button('Message Prompt Channel', on_click=send_telegram_prompt_guide_action).props('outline color=secondary')
|
||||||
|
|
||||||
with ui.grid(columns=4).classes('w-full gap-4'):
|
with ui.grid(columns=4).classes('w-full gap-4'):
|
||||||
metrics = [
|
metrics = [
|
||||||
@@ -158,14 +325,16 @@ def create_dashboard():
|
|||||||
ui.label(str(value)).style('font-size: 2.1rem; font-weight: 800; margin-top: 6px;')
|
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;')
|
ui.label(subtitle).style('font-size: 0.9rem; opacity: 0.78; margin-top: 8px;')
|
||||||
|
|
||||||
tabs = ui.tabs().classes('w-full')
|
selected_tab = _selected_tab_name()
|
||||||
overview_tab = ui.tab('Overview')
|
with ui.tabs(value=selected_tab, on_change=_store_selected_tab).classes('w-full') as tabs:
|
||||||
projects_tab = ui.tab('Projects')
|
ui.tab('Overview').props('name=overview')
|
||||||
trace_tab = ui.tab('Prompt Trace')
|
ui.tab('Projects').props('name=projects')
|
||||||
system_tab = ui.tab('System')
|
ui.tab('Prompt Trace').props('name=trace')
|
||||||
|
ui.tab('System').props('name=system')
|
||||||
|
ui.tab('Health').props('name=health')
|
||||||
|
|
||||||
with ui.tab_panels(tabs, value=overview_tab).classes('w-full'):
|
with ui.tab_panels(tabs, value=selected_tab).classes('w-full'):
|
||||||
with ui.tab_panel(overview_tab):
|
with ui.tab_panel('overview'):
|
||||||
with ui.grid(columns=2).classes('w-full gap-4'):
|
with ui.grid(columns=2).classes('w-full gap-4'):
|
||||||
with ui.card().classes('factory-panel q-pa-lg'):
|
with ui.card().classes('factory-panel q-pa-lg'):
|
||||||
ui.label('Project Pipeline').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
|
ui.label('Project Pipeline').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
|
||||||
@@ -195,7 +364,7 @@ def create_dashboard():
|
|||||||
ui.label(label).classes('factory-muted')
|
ui.label(label).classes('factory-muted')
|
||||||
ui.label(value).style('font-weight: 600; color: #3a281a;')
|
ui.label(value).style('font-weight: 600; color: #3a281a;')
|
||||||
|
|
||||||
with ui.tab_panel(projects_tab):
|
with ui.tab_panel('projects'):
|
||||||
if not projects:
|
if not projects:
|
||||||
with ui.card().classes('factory-panel q-pa-lg'):
|
with ui.card().classes('factory-panel q-pa-lg'):
|
||||||
ui.label('No project data available yet.').classes('factory-muted')
|
ui.label('No project data available yet.').classes('factory-muted')
|
||||||
@@ -249,7 +418,7 @@ def create_dashboard():
|
|||||||
else:
|
else:
|
||||||
ui.label('No audit events yet.').classes('factory-muted')
|
ui.label('No audit events yet.').classes('factory-muted')
|
||||||
|
|
||||||
with ui.tab_panel(trace_tab):
|
with ui.tab_panel('trace'):
|
||||||
with ui.card().classes('factory-panel q-pa-lg'):
|
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('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')
|
ui.label('Each prompt entry is linked to the generated files recorded after that prompt for the same project.').classes('factory-muted')
|
||||||
@@ -270,7 +439,7 @@ def create_dashboard():
|
|||||||
else:
|
else:
|
||||||
ui.label('No prompt traces recorded yet.').classes('factory-muted')
|
ui.label('No prompt traces recorded yet.').classes('factory-muted')
|
||||||
|
|
||||||
with ui.tab_panel(system_tab):
|
with ui.tab_panel('system'):
|
||||||
with ui.grid(columns=2).classes('w-full gap-4'):
|
with ui.grid(columns=2).classes('w-full gap-4'):
|
||||||
with ui.card().classes('factory-panel q-pa-lg'):
|
with ui.card().classes('factory-panel q-pa-lg'):
|
||||||
ui.label('System Logs').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
|
ui.label('System Logs').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
|
||||||
@@ -296,6 +465,21 @@ def create_dashboard():
|
|||||||
for endpoint in endpoints:
|
for endpoint in endpoints:
|
||||||
ui.label(endpoint).classes('factory-code q-mt-sm')
|
ui.label(endpoint).classes('factory-code q-mt-sm')
|
||||||
|
|
||||||
|
with ui.tab_panel('health'):
|
||||||
|
with ui.card().classes('factory-panel q-pa-lg q-mb-md'):
|
||||||
|
ui.label('Health and Diagnostics').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
|
||||||
|
ui.label('Use this page to verify runtime configuration, n8n API connectivity, and likely causes of provisioning failures.').classes('factory-muted')
|
||||||
|
ui.link('Open dedicated health page', '/health-ui')
|
||||||
|
with ui.card().classes('factory-panel q-pa-lg q-mb-md'):
|
||||||
|
ui.label('Telegram Prompt Channel').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
|
||||||
|
ui.label('Send a guide message into the same Telegram chat/channel where the bot is expected to receive prompts.').classes('factory-muted')
|
||||||
|
with ui.row().classes('justify-between w-full q-mt-sm'):
|
||||||
|
ui.label('Configured Chat ID').classes('factory-muted')
|
||||||
|
ui.label(settings.telegram_chat_id or 'Not configured').style('font-weight: 600; color: #3a281a;')
|
||||||
|
with ui.row().classes('items-center gap-2 q-mt-md'):
|
||||||
|
ui.button('Send Prompt Guide', on_click=send_telegram_prompt_guide_action).props('unelevated color=secondary')
|
||||||
|
_render_health_panels()
|
||||||
|
|
||||||
dashboard_body()
|
dashboard_body()
|
||||||
ui.timer(10.0, dashboard_body.refresh)
|
ui.timer(10.0, dashboard_body.refresh)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from alembic import command
|
from alembic import command
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
@@ -17,10 +18,31 @@ except ImportError:
|
|||||||
from models import Base
|
from models import Base
|
||||||
|
|
||||||
|
|
||||||
|
def get_database_runtime_summary() -> dict[str, str]:
|
||||||
|
"""Return a human-readable summary of the effective database backend."""
|
||||||
|
if settings.use_sqlite:
|
||||||
|
db_path = str(Path(settings.SQLITE_DB_PATH or "/tmp/ai_software_factory_test.db").expanduser().resolve())
|
||||||
|
return {
|
||||||
|
"backend": "sqlite",
|
||||||
|
"target": db_path,
|
||||||
|
"database": db_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed = urlparse(settings.database_url)
|
||||||
|
database_name = parsed.path.lstrip("/") or "unknown"
|
||||||
|
host = parsed.hostname or "unknown-host"
|
||||||
|
port = str(parsed.port or 5432)
|
||||||
|
return {
|
||||||
|
"backend": parsed.scheme.split("+", 1)[0] or "postgresql",
|
||||||
|
"target": f"{host}:{port}/{database_name}",
|
||||||
|
"database": database_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_engine() -> 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)
|
Path(db_path).expanduser().resolve().parent.mkdir(parents=True, exist_ok=True)
|
||||||
db_url = f"sqlite:///{db_path}"
|
db_url = f"sqlite:///{db_path}"
|
||||||
@@ -31,7 +53,7 @@ def get_engine() -> Engine:
|
|||||||
echo=settings.LOG_LEVEL == "DEBUG"
|
echo=settings.LOG_LEVEL == "DEBUG"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
db_url = settings.POSTGRES_URL or settings.database_url
|
db_url = settings.database_url
|
||||||
# PostgreSQL-specific configuration
|
# PostgreSQL-specific configuration
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
db_url,
|
db_url,
|
||||||
@@ -43,7 +65,7 @@ def get_engine() -> Engine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Event listener for connection checkout (PostgreSQL only)
|
# Event listener for connection checkout (PostgreSQL only)
|
||||||
if not settings.USE_SQLITE:
|
if not settings.use_sqlite:
|
||||||
@event.listens_for(engine, "checkout")
|
@event.listens_for(engine, "checkout")
|
||||||
def receive_checkout(dbapi_connection, connection_record, connection_proxy):
|
def receive_checkout(dbapi_connection, connection_record, connection_proxy):
|
||||||
"""Log connection checkout for audit purposes."""
|
"""Log connection checkout for audit purposes."""
|
||||||
@@ -100,7 +122,7 @@ def get_alembic_config(database_url: str | None = None) -> Config:
|
|||||||
alembic_ini = package_root / "alembic.ini"
|
alembic_ini = package_root / "alembic.ini"
|
||||||
config = Config(str(alembic_ini))
|
config = Config(str(alembic_ini))
|
||||||
config.set_main_option("script_location", str(package_root / "alembic"))
|
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'}"))
|
config.set_main_option("sqlalchemy.url", database_url or settings.database_url)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
@@ -116,7 +138,7 @@ def run_migrations(database_url: str | None = None) -> dict:
|
|||||||
|
|
||||||
def init_db() -> dict:
|
def init_db() -> dict:
|
||||||
"""Initialize database tables and database if needed."""
|
"""Initialize database tables and database if needed."""
|
||||||
if settings.USE_SQLITE:
|
if settings.use_sqlite:
|
||||||
result = run_migrations()
|
result = run_migrations()
|
||||||
if result["status"] == "success":
|
if result["status"] == "success":
|
||||||
print("SQLite database migrations applied successfully.")
|
print("SQLite database migrations applied successfully.")
|
||||||
@@ -131,7 +153,7 @@ def init_db() -> dict:
|
|||||||
return {'status': 'error', 'message': f'Error: {str(e)}'}
|
return {'status': 'error', 'message': f'Error: {str(e)}'}
|
||||||
else:
|
else:
|
||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
db_url = settings.POSTGRES_URL or settings.database_url
|
db_url = settings.database_url
|
||||||
db_name = db_url.split('/')[-1] if '/' in db_url else 'ai_software_factory'
|
db_name = db_url.split('/')[-1] if '/' in db_url else 'ai_software_factory'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -180,7 +202,7 @@ def init_db() -> dict:
|
|||||||
|
|
||||||
def drop_db() -> dict:
|
def drop_db() -> dict:
|
||||||
"""Drop all database tables (use with caution!)."""
|
"""Drop all database tables (use with caution!)."""
|
||||||
if settings.USE_SQLITE:
|
if settings.use_sqlite:
|
||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
try:
|
try:
|
||||||
Base.metadata.drop_all(bind=engine)
|
Base.metadata.drop_all(bind=engine)
|
||||||
@@ -190,7 +212,7 @@ def drop_db() -> dict:
|
|||||||
print(f"Error dropping SQLite tables: {str(e)}")
|
print(f"Error dropping SQLite tables: {str(e)}")
|
||||||
return {'status': 'error', 'message': str(e)}
|
return {'status': 'error', 'message': str(e)}
|
||||||
else:
|
else:
|
||||||
db_url = settings.POSTGRES_URL or settings.database_url
|
db_url = settings.database_url
|
||||||
db_name = db_url.split('/')[-1] if '/' in db_url else 'ai_software_factory'
|
db_name = db_url.split('/')[-1] if '/' in db_url else 'ai_software_factory'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ from fastapi.responses import RedirectResponse
|
|||||||
from nicegui import app, ui
|
from nicegui import app, ui
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .dashboard_ui import create_dashboard
|
from .dashboard_ui import create_dashboard, create_health_page
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from dashboard_ui import create_dashboard
|
from dashboard_ui import create_dashboard, create_health_page
|
||||||
|
|
||||||
|
|
||||||
def init(fastapi_app: FastAPI, storage_secret: str = 'Secr2t!') -> None:
|
def init(fastapi_app: FastAPI, storage_secret: str = 'Secr2t!') -> None:
|
||||||
@@ -38,6 +38,10 @@ def init(fastapi_app: FastAPI, storage_secret: str = 'Secr2t!') -> None:
|
|||||||
def show() -> None:
|
def show() -> None:
|
||||||
render_dashboard_page()
|
render_dashboard_page()
|
||||||
|
|
||||||
|
@ui.page('/health-ui')
|
||||||
|
def health_ui() -> None:
|
||||||
|
create_health_page()
|
||||||
|
|
||||||
@fastapi_app.get('/dashboard', include_in_schema=False)
|
@fastapi_app.get('/dashboard', include_in_schema=False)
|
||||||
def dashboard_redirect() -> RedirectResponse:
|
def dashboard_redirect() -> RedirectResponse:
|
||||||
return RedirectResponse(url='/', status_code=307)
|
return RedirectResponse(url='/', status_code=307)
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
echo "Hello world"
|
|
||||||
@@ -13,6 +13,7 @@ The NiceGUI frontend provides:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -27,6 +28,7 @@ try:
|
|||||||
from . import __version__, frontend
|
from . import __version__, frontend
|
||||||
from . import database as database_module
|
from . import database as database_module
|
||||||
from .agents.database_manager import DatabaseManager
|
from .agents.database_manager import DatabaseManager
|
||||||
|
from .agents.request_interpreter import RequestInterpreter
|
||||||
from .agents.orchestrator import AgentOrchestrator
|
from .agents.orchestrator import AgentOrchestrator
|
||||||
from .agents.n8n_setup import N8NSetupAgent
|
from .agents.n8n_setup import N8NSetupAgent
|
||||||
from .agents.ui_manager import UIManager
|
from .agents.ui_manager import UIManager
|
||||||
@@ -35,6 +37,7 @@ except ImportError:
|
|||||||
import frontend
|
import frontend
|
||||||
import database as database_module
|
import database as database_module
|
||||||
from agents.database_manager import DatabaseManager
|
from agents.database_manager import DatabaseManager
|
||||||
|
from agents.request_interpreter import RequestInterpreter
|
||||||
from agents.orchestrator import AgentOrchestrator
|
from agents.orchestrator import AgentOrchestrator
|
||||||
from agents.n8n_setup import N8NSetupAgent
|
from agents.n8n_setup import N8NSetupAgent
|
||||||
from agents.ui_manager import UIManager
|
from agents.ui_manager import UIManager
|
||||||
@@ -42,7 +45,18 @@ except ImportError:
|
|||||||
|
|
||||||
__version__ = "0.0.1"
|
__version__ = "0.0.1"
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_app: FastAPI):
|
||||||
|
"""Log resolved runtime configuration when the app starts."""
|
||||||
|
runtime = database_module.get_database_runtime_summary()
|
||||||
|
print(
|
||||||
|
f"Runtime configuration: database_backend={runtime['backend']} target={runtime['target']}"
|
||||||
|
)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
DbSession = Annotated[Session, Depends(database_module.get_db)]
|
DbSession = Annotated[Session, Depends(database_module.get_db)]
|
||||||
PROJECT_ID_PATTERN = re.compile(r"[^a-z0-9]+")
|
PROJECT_ID_PATTERN = re.compile(r"[^a-z0-9]+")
|
||||||
@@ -67,6 +81,15 @@ class N8NSetupRequest(BaseModel):
|
|||||||
force_update: bool = False
|
force_update: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class FreeformSoftwareRequest(BaseModel):
|
||||||
|
"""Request body for free-form software generation."""
|
||||||
|
|
||||||
|
prompt_text: str = Field(min_length=1)
|
||||||
|
source: str = 'telegram'
|
||||||
|
chat_id: str | None = None
|
||||||
|
chat_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def _build_project_id(name: str) -> str:
|
def _build_project_id(name: str) -> str:
|
||||||
"""Create a stable project id from the requested name."""
|
"""Create a stable project id from the requested name."""
|
||||||
slug = PROJECT_ID_PATTERN.sub("-", name.strip().lower()).strip("-") or "project"
|
slug = PROJECT_ID_PATTERN.sub("-", name.strip().lower()).strip("-") or "project"
|
||||||
@@ -132,6 +155,49 @@ def _compose_prompt_text(request: SoftwareRequest) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_generation(
|
||||||
|
request: SoftwareRequest,
|
||||||
|
db: Session,
|
||||||
|
prompt_text: str | None = None,
|
||||||
|
prompt_actor: str = 'api',
|
||||||
|
) -> dict:
|
||||||
|
"""Run the shared generation pipeline for a structured request."""
|
||||||
|
database_module.init_db()
|
||||||
|
|
||||||
|
project_id = _build_project_id(request.name)
|
||||||
|
resolved_prompt_text = prompt_text or _compose_prompt_text(request)
|
||||||
|
orchestrator = AgentOrchestrator(
|
||||||
|
project_id=project_id,
|
||||||
|
project_name=request.name,
|
||||||
|
description=request.description,
|
||||||
|
features=request.features,
|
||||||
|
tech_stack=request.tech_stack,
|
||||||
|
db=db,
|
||||||
|
prompt_text=resolved_prompt_text,
|
||||||
|
prompt_actor=prompt_actor,
|
||||||
|
)
|
||||||
|
result = await orchestrator.run()
|
||||||
|
|
||||||
|
manager = DatabaseManager(db)
|
||||||
|
manager.log_system_event(
|
||||||
|
component='api',
|
||||||
|
level='INFO' if result['status'] == 'completed' else 'ERROR',
|
||||||
|
message=f"Generated project {project_id} with {len(result.get('changed_files', []))} artifact(s)",
|
||||||
|
)
|
||||||
|
|
||||||
|
history = manager.get_project_by_id(project_id)
|
||||||
|
project_logs = manager.get_project_logs(history.id)
|
||||||
|
response_data = _serialize_project(history)
|
||||||
|
response_data['logs'] = [_serialize_project_log(log) for log in project_logs]
|
||||||
|
response_data['ui_data'] = result.get('ui_data')
|
||||||
|
response_data['features'] = request.features
|
||||||
|
response_data['tech_stack'] = request.tech_stack
|
||||||
|
response_data['project_root'] = result.get('project_root', str(_project_root(project_id)))
|
||||||
|
response_data['changed_files'] = result.get('changed_files', [])
|
||||||
|
response_data['repository'] = result.get('repository')
|
||||||
|
return {'status': result['status'], 'data': response_data}
|
||||||
|
|
||||||
|
|
||||||
def _project_root(project_id: str) -> Path:
|
def _project_root(project_id: str) -> Path:
|
||||||
"""Resolve the filesystem location for a generated project."""
|
"""Resolve the filesystem location for a generated project."""
|
||||||
return database_module.settings.projects_root / project_id
|
return database_module.settings.projects_root / project_id
|
||||||
@@ -160,6 +226,7 @@ def read_api_info():
|
|||||||
'/api',
|
'/api',
|
||||||
'/health',
|
'/health',
|
||||||
'/generate',
|
'/generate',
|
||||||
|
'/generate/text',
|
||||||
'/projects',
|
'/projects',
|
||||||
'/status/{project_id}',
|
'/status/{project_id}',
|
||||||
'/audit/projects',
|
'/audit/projects',
|
||||||
@@ -178,49 +245,55 @@ def read_api_info():
|
|||||||
@app.get('/health')
|
@app.get('/health')
|
||||||
def health_check():
|
def health_check():
|
||||||
"""Health check endpoint."""
|
"""Health check endpoint."""
|
||||||
|
runtime = database_module.get_database_runtime_summary()
|
||||||
return {
|
return {
|
||||||
'status': 'healthy',
|
'status': 'healthy',
|
||||||
'database': 'sqlite' if database_module.settings.USE_SQLITE else 'postgresql',
|
'database': runtime['backend'],
|
||||||
|
'database_target': runtime['target'],
|
||||||
|
'database_name': runtime['database'],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post('/generate')
|
@app.post('/generate')
|
||||||
async def generate_software(request: SoftwareRequest, db: DbSession):
|
async def generate_software(request: SoftwareRequest, db: DbSession):
|
||||||
"""Create and record a software-generation request."""
|
"""Create and record a software-generation request."""
|
||||||
database_module.init_db()
|
return await _run_generation(request, db)
|
||||||
|
|
||||||
project_id = _build_project_id(request.name)
|
|
||||||
prompt_text = _compose_prompt_text(request)
|
@app.post('/generate/text')
|
||||||
orchestrator = AgentOrchestrator(
|
async def generate_software_from_text(request: FreeformSoftwareRequest, db: DbSession):
|
||||||
project_id=project_id,
|
"""Interpret a free-form request and run generation."""
|
||||||
project_name=request.name,
|
if (
|
||||||
description=request.description,
|
request.source == 'telegram'
|
||||||
features=request.features,
|
and database_module.settings.telegram_chat_id
|
||||||
tech_stack=request.tech_stack,
|
and request.chat_id
|
||||||
db=db,
|
and str(request.chat_id) != str(database_module.settings.telegram_chat_id)
|
||||||
prompt_text=prompt_text,
|
):
|
||||||
|
return {
|
||||||
|
'status': 'ignored',
|
||||||
|
'message': f"Ignoring Telegram message from chat {request.chat_id}",
|
||||||
|
'source': {
|
||||||
|
'type': request.source,
|
||||||
|
'chat_id': request.chat_id,
|
||||||
|
'chat_type': request.chat_type,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
interpreted = await RequestInterpreter().interpret(request.prompt_text)
|
||||||
|
structured_request = SoftwareRequest(**interpreted)
|
||||||
|
response = await _run_generation(
|
||||||
|
structured_request,
|
||||||
|
db,
|
||||||
|
prompt_text=request.prompt_text,
|
||||||
|
prompt_actor=request.source,
|
||||||
)
|
)
|
||||||
result = await orchestrator.run()
|
response['interpreted_request'] = interpreted
|
||||||
|
response['source'] = {
|
||||||
manager = DatabaseManager(db)
|
'type': request.source,
|
||||||
manager.log_system_event(
|
'chat_id': request.chat_id,
|
||||||
component='api',
|
'chat_type': request.chat_type,
|
||||||
level='INFO' if result['status'] == 'completed' else 'ERROR',
|
}
|
||||||
message=f"Generated project {project_id} with {len(result.get('changed_files', []))} artifact(s)",
|
return response
|
||||||
)
|
|
||||||
|
|
||||||
history = manager.get_project_by_id(project_id)
|
|
||||||
project_logs = manager.get_project_logs(history.id)
|
|
||||||
response_data = _serialize_project(history)
|
|
||||||
response_data['logs'] = [_serialize_project_log(log) for log in project_logs]
|
|
||||||
response_data['ui_data'] = result.get('ui_data')
|
|
||||||
response_data['features'] = request.features
|
|
||||||
response_data['tech_stack'] = request.tech_stack
|
|
||||||
response_data['project_root'] = result.get('project_root', str(_project_root(project_id)))
|
|
||||||
response_data['changed_files'] = result.get('changed_files', [])
|
|
||||||
response_data['repository'] = result.get('repository')
|
|
||||||
|
|
||||||
return {'status': result['status'], 'data': response_data}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get('/projects')
|
@app.get('/projects')
|
||||||
@@ -308,10 +381,16 @@ async def get_n8n_health():
|
|||||||
"""Check whether the configured n8n instance is reachable."""
|
"""Check whether the configured n8n instance is reachable."""
|
||||||
api_url = _resolve_n8n_api_url()
|
api_url = _resolve_n8n_api_url()
|
||||||
if not api_url:
|
if not api_url:
|
||||||
return {'status': 'error', 'message': 'N8N_API_URL or N8N_WEBHOOK_URL is not configured'}
|
return {
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'N8N_API_URL or N8N_WEBHOOK_URL is not configured.',
|
||||||
|
'api_url': '',
|
||||||
|
'auth_configured': bool(database_module.settings.n8n_api_key),
|
||||||
|
'checks': [],
|
||||||
|
'suggestion': 'Set N8N_API_URL to the base n8n address before provisioning workflows.',
|
||||||
|
}
|
||||||
agent = N8NSetupAgent(api_url=api_url, webhook_token=database_module.settings.n8n_api_key)
|
agent = N8NSetupAgent(api_url=api_url, webhook_token=database_module.settings.n8n_api_key)
|
||||||
result = await agent.health_check()
|
return await agent.health_check()
|
||||||
return {'status': 'ok' if not result.get('error') else 'error', 'data': result}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post('/n8n/setup')
|
@app.post('/n8n/setup')
|
||||||
@@ -327,7 +406,7 @@ async def setup_n8n_workflow(request: N8NSetupRequest, db: DbSession):
|
|||||||
)
|
)
|
||||||
result = await agent.setup(
|
result = await agent.setup(
|
||||||
webhook_path=request.webhook_path,
|
webhook_path=request.webhook_path,
|
||||||
backend_url=request.backend_url or f"{database_module.settings.backend_public_url}/generate",
|
backend_url=request.backend_url or f"{database_module.settings.backend_public_url}/generate/text",
|
||||||
force_update=request.force_update,
|
force_update=request.force_update,
|
||||||
telegram_bot_token=database_module.settings.telegram_bot_token,
|
telegram_bot_token=database_module.settings.telegram_bot_token,
|
||||||
telegram_credential_name=database_module.settings.n8n_telegram_credential_name,
|
telegram_credential_name=database_module.settings.n8n_telegram_credential_name,
|
||||||
|
|||||||
Reference in New Issue
Block a user