14 Commits
0.5.0 ... 0.6.5

Author SHA1 Message Date
032139c14f release: version 0.6.5 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 1m18s
Upload Python Package / deploy (push) Successful in 3m11s
2026-04-10 22:13:40 +02:00
194d5658a6 fix: better n8n workflow, refs NOISSUE 2026-04-10 22:13:33 +02:00
b9faac8d16 release: version 0.6.4 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 52s
Upload Python Package / deploy (push) Successful in 2m14s
2026-04-10 21:47:54 +02:00
80d7716e65 fix: add Telegram helper functions, refs NOISSUE 2026-04-10 21:47:50 +02:00
321bf74aef release: version 0.6.3 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 15s
Upload Python Package / deploy (push) Successful in 1m11s
2026-04-10 21:24:44 +02:00
55ee75106c fix: n8n workflow generation, refs NOISSUE 2026-04-10 21:24:39 +02:00
b2829caa02 release: version 0.6.2 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 29s
Upload Python Package / deploy (push) Successful in 1m11s
2026-04-10 21:14:12 +02:00
d4b280cf75 fix: fix Quasar layout issues, refs NOISSUE 2026-04-10 21:14:09 +02:00
806db8537b release: version 0.6.1 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 22s
Upload Python Package / deploy (push) Successful in 37s
2026-04-10 21:00:30 +02:00
360ed5c6f3 fix: fix commit for version push, refs NOISSUE 2026-04-10 21:00:26 +02:00
4b9eb2f359 chore: add more health info for n8n, refs NOISSUE 2026-04-10 20:55:43 +02:00
ebfcfb969a release: version 0.6.0 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 17s
Upload Python Package / deploy (push) Successful in 42s
2026-04-10 20:43:36 +02:00
56b05eb686 feat(api): expose database target in health refs NOISSUE 2026-04-10 20:39:36 +02:00
59a7e9787e fix(db): prefer postgres config in production refs NOISSUE 2026-04-10 20:37:31 +02:00
13 changed files with 832 additions and 109 deletions

View File

@@ -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

View File

@@ -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 |

View File

@@ -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

View File

@@ -1 +1 @@
0.5.0 0.6.5

View File

@@ -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",

View 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,
}

View File

@@ -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."""

View File

@@ -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}"

View File

@@ -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)

View File

@@ -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:

View File

@@ -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)

View File

@@ -1,3 +0,0 @@
#!/bin/bash
echo "Hello world"

View File

@@ -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,