diff --git a/ai_software_factory/agents/llm_service.py b/ai_software_factory/agents/llm_service.py index 8ae16f7..9112205 100644 --- a/ai_software_factory/agents/llm_service.py +++ b/ai_software_factory/agents/llm_service.py @@ -3,6 +3,8 @@ from __future__ import annotations import json +from urllib import error as urllib_error +from urllib import request as urllib_request try: from .gitea import GiteaAPI @@ -297,6 +299,27 @@ class LLMServiceClient: except Exception as exc: return None, {'error': str(exc)}, str(exc) + @staticmethod + def extract_error_message(trace: dict | None) -> str | None: + """Extract the most useful provider error message from a trace payload.""" + if not isinstance(trace, dict): + return None + raw_response = trace.get('raw_response') if isinstance(trace.get('raw_response'), dict) else {} + provider_response = raw_response.get('provider_response') if isinstance(raw_response.get('provider_response'), dict) else {} + candidate_errors = [ + provider_response.get('error'), + raw_response.get('error'), + trace.get('error'), + ] + raw_responses = trace.get('raw_responses') if isinstance(trace.get('raw_responses'), list) else [] + for payload in reversed(raw_responses): + if isinstance(payload, dict) and payload.get('error'): + candidate_errors.append(payload.get('error')) + for candidate in candidate_errors: + if candidate: + return str(candidate).strip() + return None + def _compose_system_prompt(self, stage: str, stage_prompt: str) -> str: """Merge the stage prompt with configured guardrails.""" sections = [stage_prompt.strip()] + self._guardrail_sections(stage) @@ -391,4 +414,118 @@ class LLMServiceClient: 'tool_context_limit': settings.llm_tool_context_limit, 'max_tool_call_rounds': settings.llm_max_tool_call_rounds, 'gitea_live_tools_configured': bool(settings.gitea_url and settings.gitea_token), + } + + def health_check_sync(self) -> dict: + """Synchronously check Ollama reachability and configured model availability.""" + if not self.ollama_url: + return { + 'status': 'error', + 'message': 'OLLAMA_URL is not configured.', + 'ollama_url': 'Not configured', + 'model': self.model, + 'checks': [], + 'suggestion': 'Set OLLAMA_URL to the reachable Ollama base URL.', + } + + tags_url = f'{self.ollama_url}/api/tags' + try: + req = urllib_request.Request(tags_url, headers={'User-Agent': 'AI-Software-Factory'}, method='GET') + with urllib_request.urlopen(req, timeout=5) as resp: + raw_body = resp.read().decode('utf-8') + payload = json.loads(raw_body) if raw_body else {} + except urllib_error.HTTPError as exc: + body = exc.read().decode('utf-8') if exc.fp else '' + message = body or str(exc) + return { + 'status': 'error', + 'message': f'Ollama returned HTTP {exc.code}: {message}', + 'ollama_url': self.ollama_url, + 'model': self.model, + 'checks': [ + { + 'name': 'api_tags', + 'ok': False, + 'status_code': exc.code, + 'url': tags_url, + 'message': message, + } + ], + 'suggestion': 'Verify OLLAMA_URL points to the Ollama service and that the API is reachable.', + } + except Exception as exc: + return { + 'status': 'error', + 'message': f'Unable to reach Ollama: {exc}', + 'ollama_url': self.ollama_url, + 'model': self.model, + 'checks': [ + { + 'name': 'api_tags', + 'ok': False, + 'status_code': None, + 'url': tags_url, + 'message': str(exc), + } + ], + 'suggestion': 'Verify OLLAMA_URL resolves from the running factory process and that Ollama is listening on that address.', + } + + models = payload.get('models') if isinstance(payload, dict) else [] + model_names: list[str] = [] + if isinstance(models, list): + for model_entry in models: + if not isinstance(model_entry, dict): + continue + name = str(model_entry.get('name') or model_entry.get('model') or '').strip() + if name: + model_names.append(name) + + requested = (self.model or '').strip() + requested_base = requested.split(':', 1)[0] + model_available = any( + name == requested or name.startswith(f'{requested}:') or name.split(':', 1)[0] == requested_base + for name in model_names + ) + checks = [ + { + 'name': 'api_tags', + 'ok': True, + 'status_code': 200, + 'url': tags_url, + 'message': f'Loaded {len(model_names)} model entries.', + }, + { + 'name': 'configured_model', + 'ok': model_available, + 'status_code': None, + 'url': None, + 'message': ( + f'Configured model {requested} is available.' + if model_available else + f'Configured model {requested} was not found in Ollama tags.' + ), + }, + ] + if model_available: + return { + 'status': 'success', + 'message': f'Ollama is reachable and model {requested} is available.', + 'ollama_url': self.ollama_url, + 'model': requested, + 'model_available': True, + 'model_count': len(model_names), + 'models': model_names[:10], + 'checks': checks, + } + return { + 'status': 'error', + 'message': f'Ollama is reachable, but model {requested} is not available.', + 'ollama_url': self.ollama_url, + 'model': requested, + 'model_available': False, + 'model_count': len(model_names), + 'models': model_names[:10], + 'checks': checks, + 'suggestion': f'Pull or configure the model {requested}, or update OLLAMA_MODEL to a model that exists in Ollama.', } \ No newline at end of file diff --git a/ai_software_factory/agents/orchestrator.py b/ai_software_factory/agents/orchestrator.py index 21f8077..def9253 100644 --- a/ai_software_factory/agents/orchestrator.py +++ b/ai_software_factory/agents/orchestrator.py @@ -392,6 +392,9 @@ class AgentOrchestrator: f"existing_workspace={has_existing_files}", ) if not content: + detail = LLMServiceClient.extract_error_message(trace) + if detail: + raise RuntimeError(f'LLM code generation failed: {detail}') raise RuntimeError('LLM code generation did not return a usable response.') if not generated_files: raise RuntimeError('LLM code generation did not return any writable files.') diff --git a/ai_software_factory/agents/request_interpreter.py b/ai_software_factory/agents/request_interpreter.py index 39fd194..129e185 100644 --- a/ai_software_factory/agents/request_interpreter.py +++ b/ai_software_factory/agents/request_interpreter.py @@ -89,6 +89,9 @@ class RequestInterpreter: expect_json=True, ) if not content: + detail = self.llm_client.extract_error_message(trace) + if detail: + raise RuntimeError(f'LLM request interpretation failed: {detail}') raise RuntimeError('LLM request interpretation did not return a usable response.') try: @@ -141,6 +144,9 @@ class RequestInterpreter: expect_json=True, ) if not content: + detail = self.llm_client.extract_error_message(trace) + if detail: + raise RuntimeError(f'LLM project naming failed: {detail}') raise RuntimeError('LLM project naming did not return a usable response.') try: diff --git a/ai_software_factory/dashboard_ui.py b/ai_software_factory/dashboard_ui.py index c00dd7b..91cec6f 100644 --- a/ai_software_factory/dashboard_ui.py +++ b/ai_software_factory/dashboard_ui.py @@ -725,6 +725,20 @@ def _load_home_assistant_health_snapshot() -> dict: } +def _load_ollama_health_snapshot() -> dict: + """Load an Ollama health snapshot for UI rendering.""" + try: + return LLMServiceClient().health_check_sync() + except Exception as exc: + return { + 'status': 'error', + 'message': f'Unable to run Ollama health checks: {exc}', + 'ollama_url': settings.ollama_url or 'Not configured', + 'model': settings.OLLAMA_MODEL, + 'checks': [], + } + + def _add_dashboard_styles() -> None: """Register shared dashboard styles.""" ui.add_head_html( @@ -821,6 +835,7 @@ def _render_confirmation_dialog(title: str, message: str, confirm_label: str, on def _render_health_panels() -> None: """Render application, integration, and queue health panels.""" runtime = get_database_runtime_summary() + ollama_health = _load_ollama_health_snapshot() n8n_health = _load_n8n_health_snapshot() gitea_health = _load_gitea_health_snapshot() home_assistant_health = _load_home_assistant_health_snapshot() @@ -843,6 +858,33 @@ def _render_health_panels() -> None: 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('Ollama / LLM').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;') + ui.label(ollama_health.get('status', 'unknown').upper()).classes('factory-chip') + ui.label(ollama_health.get('message', 'No Ollama status available.')).classes('factory-muted q-mt-sm') + rows = [ + ('Ollama URL', ollama_health.get('ollama_url') or 'Not configured'), + ('Configured Model', ollama_health.get('model') or 'Not configured'), + ('Model Available', 'yes' if ollama_health.get('model_available') else 'no'), + ('Visible Models', ollama_health.get('model_count') if ollama_health.get('model_count') is not None else 'unknown'), + ] + 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 ollama_health.get('models'): + ui.label('Reported Models').style('font-size: 1rem; font-weight: 700; color: #3a281a; margin-top: 12px;') + ui.label(', '.join(str(model) for model in ollama_health.get('models', []))).classes('factory-muted') + if ollama_health.get('suggestion'): + ui.label(ollama_health['suggestion']).classes('factory-chip q-mt-md') + for check in ollama_health.get('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') + 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() @@ -930,7 +972,7 @@ def create_health_page() -> None: 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') + ui.label('Current application, Ollama, and integration connectivity diagnostics.').classes('factory-muted') with ui.row().classes('items-center gap-2'): ui.link('Back to Dashboard', '/') ui.link('Refresh Health', '/health-ui') diff --git a/ai_software_factory/main.py b/ai_software_factory/main.py index d70d1bd..3c342cb 100644 --- a/ai_software_factory/main.py +++ b/ai_software_factory/main.py @@ -202,6 +202,9 @@ async def _derive_project_id_for_request( expect_json=True, ) if not content: + detail = LLMServiceClient.extract_error_message(trace) + if detail: + raise RuntimeError(f'LLM project id naming failed: {detail}') raise RuntimeError('LLM project id naming did not return a usable response.') try: parsed = json.loads(content) @@ -565,6 +568,11 @@ def _get_home_assistant_health() -> dict: return _create_home_assistant_agent().health_check_sync() +def _get_ollama_health() -> dict: + """Return current Ollama connectivity diagnostics.""" + return LLMServiceClient().health_check_sync() + + async def _get_queue_gate_status(force: bool = False) -> dict: """Return whether queued prompts may be processed now.""" if not database_module.settings.prompt_queue_enabled: @@ -848,6 +856,7 @@ def health_check(): 'database_target': runtime['target'], 'database_name': runtime['database'], 'integrations': { + 'ollama': _get_ollama_health(), 'gitea': _get_gitea_health(), 'home_assistant': _get_home_assistant_health(), },