Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0770b254b1 | |||
| e651e3324d | |||
| bbe0279af4 | |||
| 5e5e7b4f35 |
23
HISTORY.md
23
HISTORY.md
@@ -5,11 +5,34 @@ Changelog
|
||||
(unreleased)
|
||||
------------
|
||||
|
||||
Fix
|
||||
~~~
|
||||
- Add Ollama connection health details in UI, refs NOISSUE. [Simon
|
||||
Diesenreiter]
|
||||
|
||||
|
||||
0.9.13 (2026-04-11)
|
||||
-------------------
|
||||
|
||||
Fix
|
||||
~~~
|
||||
- Fix internal server error, refs NOISSUE. [Simon Diesenreiter]
|
||||
|
||||
Other
|
||||
~~~~~
|
||||
|
||||
|
||||
0.9.12 (2026-04-11)
|
||||
-------------------
|
||||
|
||||
Fix
|
||||
~~~
|
||||
- Remove heuristic decision making fallbacks, refs NOISSUE. [Simon
|
||||
Diesenreiter]
|
||||
|
||||
Other
|
||||
~~~~~
|
||||
|
||||
|
||||
0.9.11 (2026-04-11)
|
||||
-------------------
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.9.12
|
||||
0.9.14
|
||||
|
||||
@@ -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)
|
||||
@@ -392,3 +415,117 @@ class LLMServiceClient:
|
||||
'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.',
|
||||
}
|
||||
@@ -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.')
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
@@ -283,6 +286,51 @@ def _compose_prompt_text(request: SoftwareRequest) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _generation_error_payload(
|
||||
*,
|
||||
message: str,
|
||||
request: SoftwareRequest | None = None,
|
||||
source: dict | None = None,
|
||||
interpreted_request: dict | None = None,
|
||||
routing: dict | None = None,
|
||||
) -> dict:
|
||||
"""Return a workflow-safe JSON payload for expected generation failures."""
|
||||
response = {
|
||||
'status': 'error',
|
||||
'message': message,
|
||||
'error': message,
|
||||
'summary_message': message,
|
||||
'summary_metadata': {
|
||||
'provider': None,
|
||||
'model': None,
|
||||
'fallback_used': False,
|
||||
},
|
||||
'data': {
|
||||
'history_id': None,
|
||||
'project_id': None,
|
||||
'name': request.name if request is not None else (interpreted_request or {}).get('name'),
|
||||
'description': request.description if request is not None else (interpreted_request or {}).get('description'),
|
||||
'status': 'error',
|
||||
'progress': 0,
|
||||
'message': message,
|
||||
'current_step': None,
|
||||
'error_message': message,
|
||||
'logs': [],
|
||||
'changed_files': [],
|
||||
'repository': None,
|
||||
'pull_request': None,
|
||||
'summary_message': message,
|
||||
},
|
||||
}
|
||||
if source is not None:
|
||||
response['source'] = source
|
||||
if interpreted_request is not None:
|
||||
response['interpreted_request'] = interpreted_request
|
||||
if routing is not None:
|
||||
response['routing'] = routing
|
||||
return response
|
||||
|
||||
|
||||
async def _run_generation(
|
||||
request: SoftwareRequest,
|
||||
db: Session,
|
||||
@@ -520,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:
|
||||
@@ -803,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(),
|
||||
},
|
||||
@@ -876,7 +930,15 @@ def reset_runtime_setting(setting_key: str, db: DbSession):
|
||||
@app.post('/generate')
|
||||
async def generate_software(request: SoftwareRequest, db: DbSession):
|
||||
"""Create and record a software-generation request."""
|
||||
return await _run_generation(request, db)
|
||||
try:
|
||||
return await _run_generation(request, db)
|
||||
except Exception as exc:
|
||||
DatabaseManager(db).log_system_event(
|
||||
component='api',
|
||||
level='ERROR',
|
||||
message=f"Structured generation failed: {exc}",
|
||||
)
|
||||
return _generation_error_payload(message=str(exc), request=request)
|
||||
|
||||
|
||||
@app.post('/generate/text')
|
||||
@@ -920,7 +982,22 @@ async def generate_software_from_text(request: FreeformSoftwareRequest, db: DbSe
|
||||
},
|
||||
}
|
||||
|
||||
return await _run_freeform_generation(request, db)
|
||||
try:
|
||||
return await _run_freeform_generation(request, db)
|
||||
except Exception as exc:
|
||||
DatabaseManager(db).log_system_event(
|
||||
component='api',
|
||||
level='ERROR',
|
||||
message=f"Free-form generation failed for source={request.source}: {exc}",
|
||||
)
|
||||
return _generation_error_payload(
|
||||
message=str(exc),
|
||||
source={
|
||||
'type': request.source,
|
||||
'chat_id': request.chat_id,
|
||||
'chat_type': request.chat_type,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get('/queue')
|
||||
|
||||
Reference in New Issue
Block a user