Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2eba98dff4 | |||
| c437ae0173 | |||
| 0770b254b1 | |||
| e651e3324d |
23
HISTORY.md
23
HISTORY.md
@@ -5,10 +5,33 @@ Changelog
|
|||||||
(unreleased)
|
(unreleased)
|
||||||
------------
|
------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- Increase LLM timeouts, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
|
||||||
|
0.9.14 (2026-04-11)
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- Add Ollama connection health details in UI, refs NOISSUE. [Simon
|
||||||
|
Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
|
||||||
|
0.9.13 (2026-04-11)
|
||||||
|
-------------------
|
||||||
|
|
||||||
Fix
|
Fix
|
||||||
~~~
|
~~~
|
||||||
- Fix internal server error, refs NOISSUE. [Simon Diesenreiter]
|
- Fix internal server error, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
|
||||||
0.9.12 (2026-04-11)
|
0.9.12 (2026-04-11)
|
||||||
-------------------
|
-------------------
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.9.13
|
0.9.15
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from urllib import error as urllib_error
|
||||||
|
from urllib import request as urllib_request
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .gitea import GiteaAPI
|
from .gitea import GiteaAPI
|
||||||
@@ -183,6 +185,7 @@ class LLMServiceClient:
|
|||||||
def __init__(self, ollama_url: str | None = None, model: str | None = None):
|
def __init__(self, ollama_url: str | None = None, model: str | None = None):
|
||||||
self.ollama_url = (ollama_url or settings.ollama_url).rstrip('/')
|
self.ollama_url = (ollama_url or settings.ollama_url).rstrip('/')
|
||||||
self.model = model or settings.OLLAMA_MODEL
|
self.model = model or settings.OLLAMA_MODEL
|
||||||
|
self.request_timeout_seconds = settings.llm_request_timeout_seconds
|
||||||
self.toolbox = LLMToolbox()
|
self.toolbox = LLMToolbox()
|
||||||
self.live_tool_executor = LLMLiveToolExecutor()
|
self.live_tool_executor = LLMLiveToolExecutor()
|
||||||
|
|
||||||
@@ -288,15 +291,39 @@ class LLMServiceClient:
|
|||||||
try:
|
try:
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self.request_timeout_seconds)) as session:
|
||||||
async with session.post(f'{self.ollama_url}/api/chat', json=request_payload) as resp:
|
async with session.post(f'{self.ollama_url}/api/chat', json=request_payload) as resp:
|
||||||
payload = await resp.json()
|
payload = await resp.json()
|
||||||
if 200 <= resp.status < 300:
|
if 200 <= resp.status < 300:
|
||||||
return (payload.get('message') or {}).get('content', ''), payload, None
|
return (payload.get('message') or {}).get('content', ''), payload, None
|
||||||
return None, payload, str(payload.get('error') or payload)
|
return None, payload, str(payload.get('error') or payload)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
if exc.__class__.__name__ == 'TimeoutError':
|
||||||
|
message = f'LLM request timed out after {self.request_timeout_seconds} seconds'
|
||||||
|
return None, {'error': message}, message
|
||||||
return None, {'error': str(exc)}, str(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:
|
def _compose_system_prompt(self, stage: str, stage_prompt: str) -> str:
|
||||||
"""Merge the stage prompt with configured guardrails."""
|
"""Merge the stage prompt with configured guardrails."""
|
||||||
sections = [stage_prompt.strip()] + self._guardrail_sections(stage)
|
sections = [stage_prompt.strip()] + self._guardrail_sections(stage)
|
||||||
@@ -392,3 +419,117 @@ class LLMServiceClient:
|
|||||||
'max_tool_call_rounds': settings.llm_max_tool_call_rounds,
|
'max_tool_call_rounds': settings.llm_max_tool_call_rounds,
|
||||||
'gitea_live_tools_configured': bool(settings.gitea_url and settings.gitea_token),
|
'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}",
|
f"existing_workspace={has_existing_files}",
|
||||||
)
|
)
|
||||||
if not content:
|
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.')
|
raise RuntimeError('LLM code generation did not return a usable response.')
|
||||||
if not generated_files:
|
if not generated_files:
|
||||||
raise RuntimeError('LLM code generation did not return any writable files.')
|
raise RuntimeError('LLM code generation did not return any writable files.')
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ class RequestInterpreter:
|
|||||||
expect_json=True,
|
expect_json=True,
|
||||||
)
|
)
|
||||||
if not content:
|
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.')
|
raise RuntimeError('LLM request interpretation did not return a usable response.')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -141,6 +144,9 @@ class RequestInterpreter:
|
|||||||
expect_json=True,
|
expect_json=True,
|
||||||
)
|
)
|
||||||
if not content:
|
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.')
|
raise RuntimeError('LLM project naming did not return a usable response.')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ class Settings(BaseSettings):
|
|||||||
# Ollama settings computed from environment
|
# Ollama settings computed from environment
|
||||||
OLLAMA_URL: str = "http://ollama:11434"
|
OLLAMA_URL: str = "http://ollama:11434"
|
||||||
OLLAMA_MODEL: str = "llama3"
|
OLLAMA_MODEL: str = "llama3"
|
||||||
|
LLM_REQUEST_TIMEOUT_SECONDS: int = 240
|
||||||
LLM_GUARDRAIL_PROMPT: str = (
|
LLM_GUARDRAIL_PROMPT: str = (
|
||||||
"You are operating inside AI Software Factory. Follow the requested schema exactly, "
|
"You are operating inside AI Software Factory. Follow the requested schema exactly, "
|
||||||
"treat provided tool outputs as authoritative, and do not invent repositories, issues, pull requests, or delivery facts."
|
"treat provided tool outputs as authoritative, and do not invent repositories, issues, pull requests, or delivery facts."
|
||||||
@@ -613,6 +614,11 @@ class Settings(BaseSettings):
|
|||||||
"""Get the maximum number of queued prompts to process in one batch."""
|
"""Get the maximum number of queued prompts to process in one batch."""
|
||||||
return max(int(_resolve_runtime_setting_value('PROMPT_QUEUE_MAX_BATCH_SIZE', self.PROMPT_QUEUE_MAX_BATCH_SIZE)), 1)
|
return max(int(_resolve_runtime_setting_value('PROMPT_QUEUE_MAX_BATCH_SIZE', self.PROMPT_QUEUE_MAX_BATCH_SIZE)), 1)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def llm_request_timeout_seconds(self) -> int:
|
||||||
|
"""Get the outbound provider timeout for one LLM request."""
|
||||||
|
return max(int(_resolve_runtime_setting_value('LLM_REQUEST_TIMEOUT_SECONDS', self.LLM_REQUEST_TIMEOUT_SECONDS)), 1)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def projects_root(self) -> Path:
|
def projects_root(self) -> Path:
|
||||||
"""Get the root directory for generated project artifacts."""
|
"""Get the root directory for generated project artifacts."""
|
||||||
|
|||||||
@@ -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:
|
def _add_dashboard_styles() -> None:
|
||||||
"""Register shared dashboard styles."""
|
"""Register shared dashboard styles."""
|
||||||
ui.add_head_html(
|
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:
|
def _render_health_panels() -> None:
|
||||||
"""Render application, integration, and queue health panels."""
|
"""Render application, integration, and queue health panels."""
|
||||||
runtime = get_database_runtime_summary()
|
runtime = get_database_runtime_summary()
|
||||||
|
ollama_health = _load_ollama_health_snapshot()
|
||||||
n8n_health = _load_n8n_health_snapshot()
|
n8n_health = _load_n8n_health_snapshot()
|
||||||
gitea_health = _load_gitea_health_snapshot()
|
gitea_health = _load_gitea_health_snapshot()
|
||||||
home_assistant_health = _load_home_assistant_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(label).classes('factory-muted')
|
||||||
ui.label(str(value)).style('font-weight: 600; color: #3a281a;')
|
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'):
|
with ui.card().classes('factory-panel q-pa-lg'):
|
||||||
ui.label('n8n Connection Status').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
|
ui.label('n8n Connection Status').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
|
||||||
status_label = n8n_health.get('status', 'unknown').upper()
|
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.row().classes('items-center justify-between w-full'):
|
||||||
with ui.column().classes('gap-1'):
|
with ui.column().classes('gap-1'):
|
||||||
ui.label('Factory Health').style('font-size: 2rem; font-weight: 800; color: #302116;')
|
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'):
|
with ui.row().classes('items-center gap-2'):
|
||||||
ui.link('Back to Dashboard', '/')
|
ui.link('Back to Dashboard', '/')
|
||||||
ui.link('Refresh Health', '/health-ui')
|
ui.link('Refresh Health', '/health-ui')
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ try:
|
|||||||
from .agents.orchestrator import AgentOrchestrator
|
from .agents.orchestrator import AgentOrchestrator
|
||||||
from .agents.n8n_setup import N8NSetupAgent
|
from .agents.n8n_setup import N8NSetupAgent
|
||||||
from .agents.prompt_workflow import PromptWorkflowManager
|
from .agents.prompt_workflow import PromptWorkflowManager
|
||||||
|
from .agents.telegram import TelegramHandler
|
||||||
from .agents.ui_manager import UIManager
|
from .agents.ui_manager import UIManager
|
||||||
from .models import ProjectHistory, ProjectLog, SystemLog
|
from .models import ProjectHistory, ProjectLog, SystemLog
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -49,6 +50,7 @@ except ImportError:
|
|||||||
from agents.orchestrator import AgentOrchestrator
|
from agents.orchestrator import AgentOrchestrator
|
||||||
from agents.n8n_setup import N8NSetupAgent
|
from agents.n8n_setup import N8NSetupAgent
|
||||||
from agents.prompt_workflow import PromptWorkflowManager
|
from agents.prompt_workflow import PromptWorkflowManager
|
||||||
|
from agents.telegram import TelegramHandler
|
||||||
from agents.ui_manager import UIManager
|
from agents.ui_manager import UIManager
|
||||||
from models import ProjectHistory, ProjectLog, SystemLog
|
from models import ProjectHistory, ProjectLog, SystemLog
|
||||||
|
|
||||||
@@ -202,6 +204,9 @@ async def _derive_project_id_for_request(
|
|||||||
expect_json=True,
|
expect_json=True,
|
||||||
)
|
)
|
||||||
if not content:
|
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.')
|
raise RuntimeError('LLM project id naming did not return a usable response.')
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(content)
|
parsed = json.loads(content)
|
||||||
@@ -253,6 +258,63 @@ def _ensure_summary_mentions_pull_request(summary_message: str, pull_request: di
|
|||||||
return f"{summary_message}{separator} Review PR: {pr_url}"
|
return f"{summary_message}{separator} Review PR: {pr_url}"
|
||||||
|
|
||||||
|
|
||||||
|
def _should_queue_telegram_request(request: FreeformSoftwareRequest) -> bool:
|
||||||
|
"""Return whether a Telegram request should be accepted for background processing."""
|
||||||
|
return (
|
||||||
|
request.source == 'telegram'
|
||||||
|
and bool(request.chat_id)
|
||||||
|
and bool(database_module.settings.telegram_bot_token)
|
||||||
|
and not request.process_now
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_prompt_queue_processing() -> None:
|
||||||
|
"""Kick off background queue processing without blocking the current HTTP request."""
|
||||||
|
if database_module.settings.prompt_queue_enabled and not database_module.settings.prompt_queue_auto_process:
|
||||||
|
return
|
||||||
|
limit = database_module.settings.prompt_queue_max_batch_size if database_module.settings.prompt_queue_enabled else 1
|
||||||
|
force = database_module.settings.prompt_queue_force_process if database_module.settings.prompt_queue_enabled else True
|
||||||
|
task = asyncio.create_task(_process_prompt_queue_batch(limit=limit, force=force))
|
||||||
|
|
||||||
|
def _log_task_result(completed_task: asyncio.Task) -> None:
|
||||||
|
try:
|
||||||
|
completed_task.result()
|
||||||
|
except Exception as exc:
|
||||||
|
db = database_module.get_db_sync()
|
||||||
|
try:
|
||||||
|
DatabaseManager(db).log_system_event('prompt-queue', 'ERROR', f'Background queue processing failed: {exc}')
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
task.add_done_callback(_log_task_result)
|
||||||
|
|
||||||
|
|
||||||
|
async def _notify_telegram_queue_result(request: FreeformSoftwareRequest, *, response: dict | None = None, error_message: str | None = None) -> None:
|
||||||
|
"""Send the final queued result back to Telegram when chat metadata is available."""
|
||||||
|
if request.source != 'telegram' or not request.chat_id or not database_module.settings.telegram_bot_token:
|
||||||
|
return
|
||||||
|
if response is not None:
|
||||||
|
message = (
|
||||||
|
response.get('summary_message')
|
||||||
|
or (response.get('data') or {}).get('summary_message')
|
||||||
|
or response.get('message')
|
||||||
|
or 'Software generation completed.'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message = f"Software generation failed: {error_message or 'Unknown error'}"
|
||||||
|
result = await TelegramHandler(webhook_url=database_module.settings.backend_public_url).send_message(
|
||||||
|
bot_token=database_module.settings.telegram_bot_token,
|
||||||
|
chat_id=request.chat_id,
|
||||||
|
text=message,
|
||||||
|
)
|
||||||
|
if result.get('status') == 'error':
|
||||||
|
db = database_module.get_db_sync()
|
||||||
|
try:
|
||||||
|
DatabaseManager(db).log_system_event('telegram', 'ERROR', f"Unable to send queued Telegram update: {result.get('message')}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _serialize_system_log(log: SystemLog) -> dict:
|
def _serialize_system_log(log: SystemLog) -> dict:
|
||||||
"""Serialize a system log row."""
|
"""Serialize a system log row."""
|
||||||
return {
|
return {
|
||||||
@@ -565,6 +627,11 @@ def _get_home_assistant_health() -> dict:
|
|||||||
return _create_home_assistant_agent().health_check_sync()
|
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:
|
async def _get_queue_gate_status(force: bool = False) -> dict:
|
||||||
"""Return whether queued prompts may be processed now."""
|
"""Return whether queued prompts may be processed now."""
|
||||||
if not database_module.settings.prompt_queue_enabled:
|
if not database_module.settings.prompt_queue_enabled:
|
||||||
@@ -724,6 +791,7 @@ async def _process_prompt_queue_batch(limit: int = 1, force: bool = False) -> di
|
|||||||
process_now=True,
|
process_now=True,
|
||||||
)
|
)
|
||||||
response = await _run_freeform_generation(request, work_db, queue_item_id=claimed['id'])
|
response = await _run_freeform_generation(request, work_db, queue_item_id=claimed['id'])
|
||||||
|
await _notify_telegram_queue_result(request, response=response)
|
||||||
processed.append(
|
processed.append(
|
||||||
{
|
{
|
||||||
'queue_item_id': claimed['id'],
|
'queue_item_id': claimed['id'],
|
||||||
@@ -733,6 +801,7 @@ async def _process_prompt_queue_batch(limit: int = 1, force: bool = False) -> di
|
|||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
DatabaseManager(work_db).fail_queued_prompt(claimed['id'], str(exc))
|
DatabaseManager(work_db).fail_queued_prompt(claimed['id'], str(exc))
|
||||||
|
await _notify_telegram_queue_result(request, error_message=str(exc))
|
||||||
processed.append({'queue_item_id': claimed['id'], 'status': 'failed', 'error': str(exc)})
|
processed.append({'queue_item_id': claimed['id'], 'status': 'failed', 'error': str(exc)})
|
||||||
finally:
|
finally:
|
||||||
work_db.close()
|
work_db.close()
|
||||||
@@ -848,6 +917,7 @@ def health_check():
|
|||||||
'database_target': runtime['target'],
|
'database_target': runtime['target'],
|
||||||
'database_name': runtime['database'],
|
'database_name': runtime['database'],
|
||||||
'integrations': {
|
'integrations': {
|
||||||
|
'ollama': _get_ollama_health(),
|
||||||
'gitea': _get_gitea_health(),
|
'gitea': _get_gitea_health(),
|
||||||
'home_assistant': _get_home_assistant_health(),
|
'home_assistant': _get_home_assistant_health(),
|
||||||
},
|
},
|
||||||
@@ -951,7 +1021,7 @@ async def generate_software_from_text(request: FreeformSoftwareRequest, db: DbSe
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.source == 'telegram' and database_module.settings.prompt_queue_enabled and not request.process_now:
|
if _should_queue_telegram_request(request):
|
||||||
manager = DatabaseManager(db)
|
manager = DatabaseManager(db)
|
||||||
queue_item = manager.enqueue_prompt(
|
queue_item = manager.enqueue_prompt(
|
||||||
prompt_text=request.prompt_text,
|
prompt_text=request.prompt_text,
|
||||||
@@ -960,12 +1030,19 @@ async def generate_software_from_text(request: FreeformSoftwareRequest, db: DbSe
|
|||||||
chat_type=request.chat_type,
|
chat_type=request.chat_type,
|
||||||
source_context={'chat_id': request.chat_id, 'chat_type': request.chat_type},
|
source_context={'chat_id': request.chat_id, 'chat_type': request.chat_type},
|
||||||
)
|
)
|
||||||
|
queue_gate = await _get_queue_gate_status(force=False)
|
||||||
|
if not database_module.settings.prompt_queue_enabled or database_module.settings.prompt_queue_auto_process:
|
||||||
|
_schedule_prompt_queue_processing()
|
||||||
return {
|
return {
|
||||||
'status': 'queued',
|
'status': 'queued',
|
||||||
'message': 'Prompt queued for energy-aware processing.',
|
'message': (
|
||||||
|
'Prompt accepted for background processing.'
|
||||||
|
if not database_module.settings.prompt_queue_enabled else
|
||||||
|
'Prompt queued for background processing.'
|
||||||
|
),
|
||||||
'queue_item': queue_item,
|
'queue_item': queue_item,
|
||||||
'queue_summary': manager.get_prompt_queue_summary(),
|
'queue_summary': manager.get_prompt_queue_summary(),
|
||||||
'queue_gate': await _get_queue_gate_status(force=False),
|
'queue_gate': queue_gate,
|
||||||
'source': {
|
'source': {
|
||||||
'type': request.source,
|
'type': request.source,
|
||||||
'chat_id': request.chat_id,
|
'chat_id': request.chat_id,
|
||||||
|
|||||||
Reference in New Issue
Block a user