diff --git a/README.md b/README.md index 82ce09c..7dc6705 100644 --- a/README.md +++ b/README.md @@ -86,11 +86,14 @@ docker-compose up -d 1. **Send a request via Telegram:** ``` - Name: My Awesome App - Description: A web application for managing tasks - Features: user authentication, task CRUD, notifications + Build an internal task management app for our operations team. + It should support user authentication, task CRUD, notifications, and reporting. + 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:** Open `http://yourserver:8000/` to see the dashboard and `http://yourserver:8000/api` for API metadata @@ -109,6 +112,7 @@ If you deploy the container with PostgreSQL environment variables set, the servi | `/api` | GET | API information | | `/health` | GET | Health check | | `/generate` | POST | Generate new software | +| `/generate/text` | POST | Interpret free-form text and generate software | | `/status/{project_id}` | GET | Get project status | | `/projects` | GET | List all projects | diff --git a/ai_software_factory/agents/n8n_setup.py b/ai_software_factory/agents/n8n_setup.py index 01a0e69..6da87e4 100644 --- a/ai_software_factory/agents/n8n_setup.py +++ b/ai_software_factory/agents/n8n_setup.py @@ -275,9 +275,10 @@ class N8NSetupAgent: return value 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.""" normalized_path = webhook_path.strip().strip("/") or "telegram" + allowed_chat = json.dumps(str(allowed_chat_id)) if allowed_chat_id else "''" return { "name": "Telegram to AI Software Factory", "settings": {"executionOrder": "v1"}, @@ -297,13 +298,13 @@ class N8NSetupAgent: }, { "id": "parse-node", - "name": "Prepare Software Request", + "name": "Prepare Freeform Request", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [-200, 120], "parameters": { "language": "javaScript", - "jsCode": "const body = $json.body ?? $json;\nconst message = body.message ?? body;\nconst text = String(message.text ?? '').trim();\nconst lines = text.split(/\\r?\\n/);\nconst request = { name: null, description: '', features: [], tech_stack: [] };\nlet nameIndex = -1;\nlet featuresIndex = -1;\nlet techIndex = -1;\nfor (let i = 0; i < lines.length; i += 1) {\n const line = lines[i].trim();\n if (line.toLowerCase().startsWith('name:')) { request.name = line.split(':', 2)[1]?.trim() || null; nameIndex = i; }\n if (line.toLowerCase().startsWith('features:') && featuresIndex === -1) { featuresIndex = i; }\n if (line.toLowerCase().startsWith('tech stack:') && techIndex === -1) { techIndex = i; }\n}\nif (nameIndex >= 0) {\n const descriptionEnd = featuresIndex >= 0 ? featuresIndex : (techIndex >= 0 ? techIndex : lines.length);\n request.description = lines.slice(nameIndex + 1, descriptionEnd).join('\\n').replace(/^description:\\s*/i, '').trim();\n}\nfunction collectList(startIndex, fieldName) {\n if (startIndex < 0) return;\n const firstLine = lines[startIndex].split(':').slice(1).join(':').trim();\n if (firstLine && !firstLine.startsWith('-') && !firstLine.startsWith('*')) {\n request[fieldName].push(...firstLine.split(',').map(item => item.trim()).filter(Boolean));\n }\n for (const rawLine of lines.slice(startIndex + 1)) {\n const line = rawLine.trim();\n if (!line) continue;\n if (/^[A-Za-z ]+:/.test(line)) break;\n if (line.startsWith('-') || line.startsWith('*')) {\n const value = line.slice(1).trim();\n if (value) request[fieldName].push(value);\n }\n }\n}\ncollectList(featuresIndex, 'features');\ncollectList(techIndex, 'tech_stack');\nif (!request.name || request.features.length === 0) { throw new Error('Could not parse software request from Telegram message'); }\nreturn [{ json: { ...request, _source: { raw_text: text, chat_id: message.chat?.id ?? null } } }];", + "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 }} }}];", }, }, { @@ -334,8 +335,8 @@ class N8NSetupAgent: }, ], "connections": { - "Telegram Webhook": {"main": [[{"node": "Prepare Software Request", "type": "main", "index": 0}]]}, - "Prepare Software Request": {"main": [[{"node": "AI Software Factory API", "type": "main", "index": 0}]]}, + "Telegram Webhook": {"main": [[{"node": "Prepare Freeform Request", "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}]]}, }, } @@ -344,8 +345,10 @@ class N8NSetupAgent: self, backend_url: str, credential_name: str, + allowed_chat_id: str | None = None, ) -> dict: """Build a production Telegram Trigger based workflow.""" + allowed_chat = json.dumps(str(allowed_chat_id)) if allowed_chat_id else "''" return { "name": "Telegram to AI Software Factory", "settings": {"executionOrder": "v1"}, @@ -360,14 +363,14 @@ class N8NSetupAgent: "credentials": {"telegramApi": {"name": credential_name}}, }, { - "id": "parse-node", - "name": "Prepare Software Request", + "id": "filter-node", + "name": "Prepare Freeform Request", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [-180, 120], "parameters": { "language": "javaScript", - "jsCode": "const message = $json.message ?? $json;\nconst text = String(message.text ?? '').trim();\nconst lines = text.split(/\\r?\\n/);\nconst request = { name: null, description: '', features: [], tech_stack: [], _source: { raw_text: text, chat_id: message.chat?.id ?? null } };\nlet nameIndex = -1;\nlet featuresIndex = -1;\nlet techIndex = -1;\nfor (let i = 0; i < lines.length; i += 1) {\n const line = lines[i].trim();\n if (line.toLowerCase().startsWith('name:')) { request.name = line.split(':', 2)[1]?.trim() || null; nameIndex = i; }\n if (line.toLowerCase().startsWith('features:') && featuresIndex === -1) { featuresIndex = i; }\n if (line.toLowerCase().startsWith('tech stack:') && techIndex === -1) { techIndex = i; }\n}\nif (nameIndex >= 0) {\n const descriptionEnd = featuresIndex >= 0 ? featuresIndex : (techIndex >= 0 ? techIndex : lines.length);\n request.description = lines.slice(nameIndex + 1, descriptionEnd).join('\\n').replace(/^description:\\s*/i, '').trim();\n}\nfunction collectList(startIndex, fieldName) {\n if (startIndex < 0) return;\n const firstLine = lines[startIndex].split(':').slice(1).join(':').trim();\n if (firstLine && !firstLine.startsWith('-') && !firstLine.startsWith('*')) {\n request[fieldName].push(...firstLine.split(',').map(item => item.trim()).filter(Boolean));\n }\n for (const rawLine of lines.slice(startIndex + 1)) {\n const line = rawLine.trim();\n if (!line) continue;\n if (/^[A-Za-z ]+:/.test(line)) break;\n if (line.startsWith('-') || line.startsWith('*')) {\n const value = line.slice(1).trim();\n if (value) request[fieldName].push(value);\n }\n }\n}\ncollectList(featuresIndex, 'features');\ncollectList(techIndex, 'tech_stack');\nif (!request.name || request.features.length === 0) { throw new Error('Could not parse software request from Telegram message'); }\nreturn [{ json: request }];", + "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 }} }}];", }, }, { @@ -401,8 +404,8 @@ class N8NSetupAgent: }, ], "connections": { - "Telegram Trigger": {"main": [[{"node": "Prepare Software Request", "type": "main", "index": 0}]]}, - "Prepare Software Request": {"main": [[{"node": "AI Software Factory API", "type": "main", "index": 0}]]}, + "Telegram Trigger": {"main": [[{"node": "Prepare Freeform Request", "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}]]}, }, } @@ -456,7 +459,7 @@ class N8NSetupAgent: """ return await self.setup( webhook_path=webhook_path, - backend_url=f"{settings.backend_public_url}/generate", + backend_url=f"{settings.backend_public_url}/generate/text", force_update=False, ) @@ -493,7 +496,7 @@ class N8NSetupAgent: "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_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) @@ -505,11 +508,13 @@ class N8NSetupAgent: workflow = self.build_telegram_trigger_workflow( backend_url=effective_backend_url, credential_name=effective_credential_name, + allowed_chat_id=settings.telegram_chat_id, ) else: workflow = self.build_telegram_workflow( webhook_path=webhook_path, backend_url=effective_backend_url, + allowed_chat_id=settings.telegram_chat_id, ) existing = await self.get_workflow(workflow["name"]) diff --git a/ai_software_factory/agents/request_interpreter.py b/ai_software_factory/agents/request_interpreter.py new file mode 100644 index 0000000..6489570 --- /dev/null +++ b/ai_software_factory/agents/request_interpreter.py @@ -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, + } \ No newline at end of file diff --git a/ai_software_factory/agents/telegram.py b/ai_software_factory/agents/telegram.py index f2c43cc..55d0009 100644 --- a/ai_software_factory/agents/telegram.py +++ b/ai_software_factory/agents/telegram.py @@ -16,17 +16,14 @@ class TelegramHandler: lines = [ "AI Software Factory is listening in this chat.", "", - "Send prompts in this format:", - "Name: Inventory Portal", - "Description: Internal tool for stock management and purchase tracking", - "Features:", - "- role-based login", - "- inventory dashboard", - "- purchase order workflow", - "Tech Stack:", - "- fastapi", - "- postgresql", - "- nicegui", + "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}"]) diff --git a/ai_software_factory/dashboard_ui.py b/ai_software_factory/dashboard_ui.py index 53e6b4e..003f5ae 100644 --- a/ai_software_factory/dashboard_ui.py +++ b/ai_software_factory/dashboard_ui.py @@ -226,7 +226,7 @@ def create_dashboard(): agent = N8NSetupAgent(api_url=api_url, webhook_token=settings.n8n_api_key) result = await agent.setup( webhook_path='telegram', - backend_url=f'{settings.backend_public_url}/generate', + backend_url=f'{settings.backend_public_url}/generate/text', force_update=True, ) diff --git a/ai_software_factory/main.py b/ai_software_factory/main.py index eb330e9..c66d69e 100644 --- a/ai_software_factory/main.py +++ b/ai_software_factory/main.py @@ -28,6 +28,7 @@ try: from . import __version__, frontend from . import database as database_module from .agents.database_manager import DatabaseManager + from .agents.request_interpreter import RequestInterpreter from .agents.orchestrator import AgentOrchestrator from .agents.n8n_setup import N8NSetupAgent from .agents.ui_manager import UIManager @@ -36,6 +37,7 @@ except ImportError: import frontend import database as database_module from agents.database_manager import DatabaseManager + from agents.request_interpreter import RequestInterpreter from agents.orchestrator import AgentOrchestrator from agents.n8n_setup import N8NSetupAgent from agents.ui_manager import UIManager @@ -79,6 +81,15 @@ class N8NSetupRequest(BaseModel): 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: """Create a stable project id from the requested name.""" slug = PROJECT_ID_PATTERN.sub("-", name.strip().lower()).strip("-") or "project" @@ -144,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: """Resolve the filesystem location for a generated project.""" return database_module.settings.projects_root / project_id @@ -172,6 +226,7 @@ def read_api_info(): '/api', '/health', '/generate', + '/generate/text', '/projects', '/status/{project_id}', '/audit/projects', @@ -202,40 +257,43 @@ def health_check(): @app.post('/generate') async def generate_software(request: SoftwareRequest, db: DbSession): """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) - orchestrator = AgentOrchestrator( - project_id=project_id, - project_name=request.name, - description=request.description, - features=request.features, - tech_stack=request.tech_stack, - db=db, - prompt_text=prompt_text, + +@app.post('/generate/text') +async def generate_software_from_text(request: FreeformSoftwareRequest, db: DbSession): + """Interpret a free-form request and run generation.""" + if ( + request.source == 'telegram' + and database_module.settings.telegram_chat_id + and request.chat_id + and str(request.chat_id) != str(database_module.settings.telegram_chat_id) + ): + 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() - - 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} + response['interpreted_request'] = interpreted + response['source'] = { + 'type': request.source, + 'chat_id': request.chat_id, + 'chat_type': request.chat_type, + } + return response @app.get('/projects') @@ -348,7 +406,7 @@ async def setup_n8n_workflow(request: N8NSetupRequest, db: DbSession): ) result = await agent.setup( 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, telegram_bot_token=database_module.settings.telegram_bot_token, telegram_credential_name=database_module.settings.n8n_telegram_credential_name,