Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80c11511d2 | |||
| 0614f7573f | |||
| 2eba98dff4 | |||
| c437ae0173 | |||
| 0770b254b1 | |||
| e651e3324d | |||
| bbe0279af4 | |||
| 5e5e7b4f35 | |||
| 634f4326c6 | |||
| f54d3b3b7a | |||
| c147d8be78 | |||
| 9ffaa18efe | |||
| d53f3fe207 | |||
| 4f1d757dd8 | |||
| ac75cc2e3a | |||
| f7f00d4e14 |
@@ -12,7 +12,10 @@ WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
&& update-ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install dependencies
|
||||
|
||||
90
HISTORY.md
90
HISTORY.md
@@ -5,10 +5,100 @@ Changelog
|
||||
(unreleased)
|
||||
------------
|
||||
|
||||
Fix
|
||||
~~~
|
||||
- Better LLM failure tracing, refs NOISSUE. [Simon Diesenreiter]
|
||||
|
||||
|
||||
0.9.15 (2026-04-11)
|
||||
-------------------
|
||||
|
||||
Fix
|
||||
~~~
|
||||
- Increase LLM timeouts, refs NOISSUE. [Simon Diesenreiter]
|
||||
|
||||
Other
|
||||
~~~~~
|
||||
|
||||
|
||||
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 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)
|
||||
-------------------
|
||||
|
||||
Fix
|
||||
~~~
|
||||
- Project association improvements, refs NOISSUE. [Simon Diesenreiter]
|
||||
|
||||
Other
|
||||
~~~~~
|
||||
|
||||
|
||||
0.9.10 (2026-04-11)
|
||||
-------------------
|
||||
|
||||
Fix
|
||||
~~~
|
||||
- More git integration fixes, refs NOISSUE. [Simon Diesenreiter]
|
||||
|
||||
Other
|
||||
~~~~~
|
||||
|
||||
|
||||
0.9.9 (2026-04-11)
|
||||
------------------
|
||||
|
||||
Fix
|
||||
~~~
|
||||
- Add missing git binary, refs NOISSUE. [Simon Diesenreiter]
|
||||
|
||||
Other
|
||||
~~~~~
|
||||
|
||||
|
||||
0.9.8 (2026-04-11)
|
||||
------------------
|
||||
|
||||
Fix
|
||||
~~~
|
||||
- More file change fixes, refs NOISSUE. [Simon Diesenreiter]
|
||||
|
||||
Other
|
||||
~~~~~
|
||||
|
||||
|
||||
0.9.7 (2026-04-11)
|
||||
------------------
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.9.8
|
||||
0.9.16
|
||||
|
||||
@@ -2272,6 +2272,7 @@ class DatabaseManager:
|
||||
"timeline": [],
|
||||
"issues": [],
|
||||
"issue_work": [],
|
||||
"ui_data": {},
|
||||
}
|
||||
|
||||
# Get logs
|
||||
@@ -2296,6 +2297,7 @@ class DatabaseManager:
|
||||
llm_traces = self.get_llm_traces(project_id=project_id)
|
||||
correlations = self.get_prompt_change_correlations(project_id=project_id)
|
||||
code_changes, local_only_code_changes, orphan_code_changes = self._partition_code_changes(raw_code_changes, commits)
|
||||
ui_data = self._get_latest_ui_snapshot_data(history.id)
|
||||
repository = self._get_project_repository(history)
|
||||
timeline = self.get_project_timeline(project_id=project_id)
|
||||
repository_sync = self.get_repository_sync_status(project_id=project_id)
|
||||
@@ -2306,6 +2308,10 @@ class DatabaseManager:
|
||||
if commit.get('remote_status') == 'pushed' or commit.get('imported_from_remote') or commit.get('commit_url')
|
||||
]
|
||||
has_pull_request = any(pr.get('pr_state') == 'open' and not pr.get('merged') for pr in pull_requests)
|
||||
published_non_main_commits = [
|
||||
commit for commit in published_commits
|
||||
if (commit.get('branch') or '').strip() not in {'', 'main', 'master'}
|
||||
]
|
||||
if orphan_code_changes:
|
||||
delivery_status = 'uncommitted'
|
||||
delivery_message = (
|
||||
@@ -2318,12 +2324,15 @@ class DatabaseManager:
|
||||
f"{len(local_only_code_changes)} generated file change(s) were committed only in the local workspace. "
|
||||
"No remote repo push was recorded for this prompt yet."
|
||||
)
|
||||
elif published_commits and repository and repository.get('mode') == 'project' and not has_pull_request:
|
||||
elif published_non_main_commits and repository and repository.get('mode') == 'project' and not has_pull_request:
|
||||
delivery_status = 'pushed_no_pr'
|
||||
delivery_message = 'Changes were pushed to the remote repository, but no pull request is currently tracked for review.'
|
||||
elif published_commits:
|
||||
delivery_status = 'delivered'
|
||||
delivery_message = 'Generated changes were published to the tracked repository and are reviewable through the recorded pull request.'
|
||||
if has_pull_request:
|
||||
delivery_message = 'Generated changes were published to the tracked repository and are reviewable through the recorded pull request.'
|
||||
else:
|
||||
delivery_message = 'Generated changes were published directly to the tracked repository default branch.'
|
||||
else:
|
||||
delivery_status = 'pending'
|
||||
delivery_message = 'No git commit has been recorded for this project yet.'
|
||||
@@ -2395,6 +2404,7 @@ class DatabaseManager:
|
||||
"repository_sync": repository_sync,
|
||||
"issues": issues,
|
||||
"issue_work": issue_work,
|
||||
"ui_data": ui_data,
|
||||
}
|
||||
|
||||
def get_prompt_events(self, project_id: str | None = None, limit: int = 100) -> list[dict]:
|
||||
@@ -2493,6 +2503,7 @@ class DatabaseManager:
|
||||
archived_project_bundles = [self.get_project_audit_data(project.project_id) for project in archived_projects[:limit]]
|
||||
all_project_bundles = [self.get_project_audit_data(project.project_id) for project in active_projects]
|
||||
all_project_bundles.extend(self.get_project_audit_data(project.project_id) for project in archived_projects)
|
||||
recent_llm_traces = self.get_llm_traces(limit=limit * 20)
|
||||
system_logs = self.db.query(SystemLog).order_by(SystemLog.created_at.desc()).limit(limit).all()
|
||||
return {
|
||||
"summary": {
|
||||
@@ -2522,6 +2533,7 @@ class DatabaseManager:
|
||||
}
|
||||
for log in system_logs
|
||||
],
|
||||
"recent_llm_traces": recent_llm_traces,
|
||||
"lineage_links": self.get_prompt_change_links(limit=limit * 10),
|
||||
"correlations": self.get_prompt_change_correlations(limit=limit),
|
||||
"prompt_queue": {
|
||||
|
||||
@@ -58,6 +58,18 @@ class GiteaAPI:
|
||||
"""Build a Gitea API URL from a relative path."""
|
||||
return f"{self.base_url}/api/v1/{path.lstrip('/')}"
|
||||
|
||||
def _normalize_pull_request_head(self, head: str | None, owner: str | None = None) -> str | None:
|
||||
"""Return a Gitea-compatible head ref for pull request creation."""
|
||||
normalized = (head or '').strip()
|
||||
if not normalized:
|
||||
return None
|
||||
if ':' in normalized:
|
||||
return normalized
|
||||
effective_owner = (owner or self.owner or '').strip()
|
||||
if not effective_owner:
|
||||
return normalized
|
||||
return f"{effective_owner}:{normalized}"
|
||||
|
||||
def build_repo_git_url(self, owner: str | None = None, repo: str | None = None) -> str | None:
|
||||
"""Build the clone URL for a repository."""
|
||||
_owner = owner or self.owner
|
||||
@@ -222,11 +234,12 @@ class GiteaAPI:
|
||||
"""Create a pull request."""
|
||||
_owner = owner or self.owner
|
||||
_repo = repo or self.repo
|
||||
normalized_head = self._normalize_pull_request_head(head, _owner)
|
||||
payload = {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"base": base,
|
||||
"head": head or f"{_owner}-{_repo}-ai-gen-{hash(title) % 10000}",
|
||||
"head": normalized_head or f"{_owner}:{_owner}-{_repo}-ai-gen-{hash(title) % 10000}",
|
||||
}
|
||||
return await self._request("POST", f"repos/{_owner}/{_repo}/pulls", payload)
|
||||
|
||||
@@ -242,11 +255,12 @@ class GiteaAPI:
|
||||
"""Synchronously create a pull request."""
|
||||
_owner = owner or self.owner
|
||||
_repo = repo or self.repo
|
||||
normalized_head = self._normalize_pull_request_head(head, _owner)
|
||||
payload = {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"base": base,
|
||||
"head": head or f"{_owner}-{_repo}-ai-gen-{hash(title) % 10000}",
|
||||
"head": normalized_head or f"{_owner}:{_owner}-{_repo}-ai-gen-{hash(title) % 10000}",
|
||||
}
|
||||
return self._request_sync("POST", f"repos/{_owner}/{_repo}/pulls", payload)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -183,6 +185,7 @@ class LLMServiceClient:
|
||||
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
|
||||
self.request_timeout_seconds = settings.llm_request_timeout_seconds
|
||||
self.toolbox = LLMToolbox()
|
||||
self.live_tool_executor = LLMLiveToolExecutor()
|
||||
|
||||
@@ -288,15 +291,39 @@ class LLMServiceClient:
|
||||
try:
|
||||
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:
|
||||
payload = await resp.json()
|
||||
if 200 <= resp.status < 300:
|
||||
return (payload.get('message') or {}).get('content', ''), payload, None
|
||||
return None, payload, str(payload.get('error') or payload)
|
||||
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)
|
||||
|
||||
@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)
|
||||
@@ -372,6 +399,7 @@ class LLMServiceClient:
|
||||
'provider': 'ollama',
|
||||
'ollama_url': self.ollama_url,
|
||||
'model': self.model,
|
||||
'request_timeout_seconds': self.request_timeout_seconds,
|
||||
'guardrails': {
|
||||
'global': settings.llm_guardrail_prompt,
|
||||
'request_interpretation': settings.llm_request_interpreter_guardrail_prompt,
|
||||
@@ -391,4 +419,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.',
|
||||
}
|
||||
@@ -124,7 +124,9 @@ class AgentOrchestrator:
|
||||
self.repo_name = repository.get('name') or self.repo_name
|
||||
self.repo_url = repository.get('url') or self.repo_url
|
||||
git_state = latest_ui.get('git') if isinstance(latest_ui.get('git'), dict) else {}
|
||||
self.branch_name = git_state.get('active_branch') or self.branch_name
|
||||
persisted_active_branch = git_state.get('active_branch')
|
||||
if persisted_active_branch and persisted_active_branch not in {'main', 'master'}:
|
||||
self.branch_name = persisted_active_branch
|
||||
if self.prompt_text:
|
||||
self.prompt_audit = self.db_manager.log_prompt_submission(
|
||||
history_id=self.history.id,
|
||||
@@ -133,6 +135,7 @@ class AgentOrchestrator:
|
||||
features=self.features,
|
||||
tech_stack=self.tech_stack,
|
||||
actor_name=self.prompt_actor,
|
||||
source=self.prompt_actor,
|
||||
related_issue={'number': self.related_issue_number} if self.related_issue_number is not None else None,
|
||||
source_context=self.prompt_source_context,
|
||||
routing=self.prompt_routing,
|
||||
@@ -167,38 +170,18 @@ class AgentOrchestrator:
|
||||
".gitignore": "__pycache__/\n*.pyc\n.venv/\n.pytest_cache/\n.mypy_cache/\n",
|
||||
}
|
||||
|
||||
def _fallback_generated_files(self) -> dict[str, str]:
|
||||
"""Deterministic fallback files when LLM generation is unavailable."""
|
||||
feature_section = "\n".join(f"- {feature}" for feature in self.features) or "- None specified"
|
||||
tech_section = "\n".join(f"- {tech}" for tech in self.tech_stack) or "- Python"
|
||||
return {
|
||||
"README.md": (
|
||||
f"# {self.project_name}\n\n"
|
||||
f"{self.description}\n\n"
|
||||
"## Features\n"
|
||||
f"{feature_section}\n\n"
|
||||
"## Tech Stack\n"
|
||||
f"{tech_section}\n"
|
||||
),
|
||||
"requirements.txt": "fastapi\nuvicorn\npytest\n",
|
||||
"main.py": (
|
||||
"from fastapi import FastAPI\n\n"
|
||||
"app = FastAPI(title=\"Generated App\")\n\n"
|
||||
"@app.get('/')\n"
|
||||
"def read_root():\n"
|
||||
f" return {{'name': '{self.project_name}', 'status': 'generated', 'features': {self.features!r}}}\n"
|
||||
),
|
||||
"tests/test_app.py": (
|
||||
"from main import read_root\n\n"
|
||||
"def test_read_root():\n"
|
||||
f" assert read_root()['name'] == '{self.project_name}'\n"
|
||||
),
|
||||
}
|
||||
|
||||
def _build_pr_branch_name(self, project_id: str) -> str:
|
||||
"""Build a stable branch name used until the PR is merged."""
|
||||
return f"ai/{project_id}"
|
||||
|
||||
def _should_use_pull_request_flow(self) -> bool:
|
||||
"""Return whether this run should deliver changes through a PR branch."""
|
||||
return self.existing_history is not None or self.active_pull_request is not None
|
||||
|
||||
def _delivery_branch_name(self) -> str:
|
||||
"""Return the git branch used for the current delivery."""
|
||||
return self.branch_name if self._should_use_pull_request_flow() else 'main'
|
||||
|
||||
def _extract_issue_number(self, prompt_text: str | None) -> int | None:
|
||||
"""Extract an issue reference from prompt text."""
|
||||
if not prompt_text:
|
||||
@@ -215,7 +198,7 @@ class AgentOrchestrator:
|
||||
"""Persist the current generation plan as an inspectable trace."""
|
||||
if not self.db_manager or not self.history or not self.prompt_audit:
|
||||
return
|
||||
planned_files = list(self._static_files().keys()) + list(self._fallback_generated_files().keys())
|
||||
planned_files = list(self._static_files().keys()) + ['README.md', 'requirements.txt', 'main.py', 'tests/test_app.py']
|
||||
self.db_manager.log_llm_trace(
|
||||
project_id=self.project_id,
|
||||
history_id=self.history.id,
|
||||
@@ -227,7 +210,7 @@ class AgentOrchestrator:
|
||||
user_prompt=self.prompt_text or self.description,
|
||||
assistant_response=(
|
||||
f"Planned files: {', '.join(planned_files)}. "
|
||||
f"Target branch: {self.branch_name}. "
|
||||
f"Target branch: {self._delivery_branch_name()}. "
|
||||
f"Repository mode: {self.ui_manager.ui_data.get('repository', {}).get('mode', 'unknown')}."
|
||||
+ (
|
||||
f" Linked issue: #{self.related_issue.get('number')} {self.related_issue.get('title')}."
|
||||
@@ -238,7 +221,7 @@ class AgentOrchestrator:
|
||||
'planned_files': planned_files,
|
||||
'features': list(self.features),
|
||||
'tech_stack': list(self.tech_stack),
|
||||
'branch': self.branch_name,
|
||||
'branch': self._delivery_branch_name(),
|
||||
'repository': self.ui_manager.ui_data.get('repository', {}),
|
||||
'related_issue': self.related_issue,
|
||||
},
|
||||
@@ -334,7 +317,6 @@ class AgentOrchestrator:
|
||||
|
||||
async def _generate_prompt_driven_files(self) -> tuple[dict[str, str], dict | None, bool]:
|
||||
"""Use the configured LLM to generate prompt-specific project files."""
|
||||
fallback_files = self._fallback_generated_files()
|
||||
workspace_context = self._collect_workspace_context()
|
||||
has_existing_files = bool(workspace_context.get('has_existing_files'))
|
||||
if has_existing_files:
|
||||
@@ -388,11 +370,40 @@ class AgentOrchestrator:
|
||||
},
|
||||
expect_json=True,
|
||||
)
|
||||
raw_generated_paths = self._extract_raw_generated_paths(content)
|
||||
generated_files = self._parse_generated_files(content)
|
||||
if has_existing_files:
|
||||
return generated_files, trace, True
|
||||
merged_files = {**fallback_files, **generated_files}
|
||||
return merged_files, trace, False
|
||||
accepted_paths = list(generated_files.keys())
|
||||
rejected_paths = [path for path in raw_generated_paths if path not in accepted_paths]
|
||||
generation_debug = {
|
||||
'raw_paths': raw_generated_paths,
|
||||
'accepted_paths': accepted_paths,
|
||||
'rejected_paths': rejected_paths,
|
||||
'existing_workspace': has_existing_files,
|
||||
}
|
||||
self.ui_manager.ui_data['generation_debug'] = generation_debug
|
||||
self._append_log(
|
||||
'LLM returned file candidates: '
|
||||
f"raw={raw_generated_paths or []}; accepted={accepted_paths or []}; rejected={rejected_paths or []}."
|
||||
)
|
||||
self._log_system_debug(
|
||||
'generation',
|
||||
'LLM file candidates '
|
||||
f"raw={raw_generated_paths or []}; accepted={accepted_paths or []}; rejected={rejected_paths or []}; "
|
||||
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.')
|
||||
if not has_existing_files:
|
||||
required_files = {'README.md', 'requirements.txt', 'main.py', 'tests/test_app.py'}
|
||||
missing_files = sorted(required_files - set(generated_files))
|
||||
if missing_files:
|
||||
raise RuntimeError(f"LLM code generation omitted required starter files: {', '.join(missing_files)}")
|
||||
return generated_files, trace, has_existing_files
|
||||
|
||||
async def _sync_issue_context(self) -> None:
|
||||
"""Sync repository issues and resolve a linked issue from the prompt when present."""
|
||||
@@ -552,11 +563,15 @@ class AgentOrchestrator:
|
||||
self.ui_manager.ui_data.setdefault('git', {})['remote_error'] = str(exc)
|
||||
self._append_log(f'Initial main push skipped: {exc}')
|
||||
|
||||
if self.git_manager.branch_exists(self.branch_name):
|
||||
self.git_manager.checkout_branch(self.branch_name)
|
||||
delivery_branch = self._delivery_branch_name()
|
||||
if self._should_use_pull_request_flow():
|
||||
if self.git_manager.branch_exists(self.branch_name):
|
||||
self.git_manager.checkout_branch(self.branch_name)
|
||||
else:
|
||||
self.git_manager.checkout_branch(self.branch_name, create=True, start_point='main')
|
||||
else:
|
||||
self.git_manager.checkout_branch(self.branch_name, create=True, start_point='main')
|
||||
self.ui_manager.ui_data.setdefault('git', {})['active_branch'] = self.branch_name
|
||||
self.git_manager.checkout_branch('main')
|
||||
self.ui_manager.ui_data.setdefault('git', {})['active_branch'] = delivery_branch
|
||||
|
||||
async def _ensure_pull_request(self) -> dict | None:
|
||||
"""Create the project pull request on first delivery and reuse it later."""
|
||||
@@ -573,6 +588,16 @@ class AgentOrchestrator:
|
||||
f"Prompt: {self.prompt_text or self.description}\n\n"
|
||||
f"Branch: {self.branch_name}"
|
||||
)
|
||||
pull_request_debug = self.ui_manager.ui_data.setdefault('git', {}).setdefault('pull_request_debug', {})
|
||||
pull_request_request = {
|
||||
'owner': self.repo_owner,
|
||||
'repo': self.repo_name,
|
||||
'title': title,
|
||||
'body': body,
|
||||
'base': 'main',
|
||||
'head': self.gitea_api._normalize_pull_request_head(self.branch_name, self.repo_owner) or self.branch_name,
|
||||
}
|
||||
pull_request_debug['request'] = pull_request_request
|
||||
result = await self.gitea_api.create_pull_request(
|
||||
title=title,
|
||||
body=body,
|
||||
@@ -581,7 +606,9 @@ class AgentOrchestrator:
|
||||
base='main',
|
||||
head=self.branch_name,
|
||||
)
|
||||
pull_request_debug['response'] = result
|
||||
if result.get('error'):
|
||||
pull_request_debug['status'] = 'error'
|
||||
raise RuntimeError(f"Unable to create pull request: {result.get('error')}")
|
||||
|
||||
pr_number = result.get('number') or result.get('id') or 0
|
||||
@@ -596,6 +623,8 @@ class AgentOrchestrator:
|
||||
'merged': bool(result.get('merged')),
|
||||
'pr_state': result.get('state', 'open'),
|
||||
}
|
||||
pull_request_debug['status'] = 'created'
|
||||
pull_request_debug['resolved'] = pr_data
|
||||
if self.db_manager and self.history:
|
||||
self.db_manager.save_pr_data(self.history.id, pr_data)
|
||||
self.active_pull_request = self.db_manager.get_open_pull_request(project_id=self.project_id) if self.db_manager else pr_data
|
||||
@@ -607,16 +636,17 @@ class AgentOrchestrator:
|
||||
repository = self.ui_manager.ui_data.get("repository") or {}
|
||||
if not self._repository_supports_remote_delivery(repository):
|
||||
return None
|
||||
push_result = await self._push_branch(self.branch_name)
|
||||
delivery_branch = self._delivery_branch_name()
|
||||
push_result = await self._push_branch(delivery_branch)
|
||||
if push_result is None:
|
||||
return None
|
||||
pull_request = await self._ensure_pull_request()
|
||||
pull_request = await self._ensure_pull_request() if self._should_use_pull_request_flow() else None
|
||||
commit_url = self.gitea_api.build_commit_url(commit_hash, owner=self.repo_owner, repo=self.repo_name)
|
||||
compare_url = self.gitea_api.build_compare_url(base_commit, commit_hash, owner=self.repo_owner, repo=self.repo_name) if base_commit else None
|
||||
remote_record = {
|
||||
"status": "pushed",
|
||||
"remote": push_result.get('remote'),
|
||||
"branch": self.branch_name,
|
||||
"branch": delivery_branch,
|
||||
"commit_url": commit_url,
|
||||
"compare_url": compare_url,
|
||||
"changed_files": changed_files,
|
||||
@@ -626,7 +656,10 @@ class AgentOrchestrator:
|
||||
repository["last_commit_url"] = commit_url
|
||||
if compare_url:
|
||||
repository["last_compare_url"] = compare_url
|
||||
self._append_log(f"Pushed generated commit to {self.repo_owner}/{self.repo_name}.")
|
||||
if pull_request:
|
||||
self._append_log(f"Pushed generated commit to {self.repo_owner}/{self.repo_name} and updated the delivery pull request.")
|
||||
else:
|
||||
self._append_log(f"Pushed generated commit directly to {self.repo_owner}/{self.repo_name} on {delivery_branch}.")
|
||||
return remote_record
|
||||
|
||||
def _build_diff_text(self, relative_path: str, previous_content: str, new_content: str) -> str:
|
||||
@@ -647,6 +680,35 @@ class AgentOrchestrator:
|
||||
if self.db_manager and self.history:
|
||||
self.db_manager._log_action(self.history.id, "INFO", message)
|
||||
|
||||
def _log_system_debug(self, component: str, message: str, level: str = 'INFO') -> None:
|
||||
"""Persist a system-level debug breadcrumb for generation and git decisions."""
|
||||
if not self.db_manager:
|
||||
return
|
||||
self.db_manager.log_system_event(component=component, level=level, message=f"{self.project_id}: {message}")
|
||||
|
||||
def _extract_raw_generated_paths(self, content: str | None) -> list[str]:
|
||||
"""Return all file paths proposed by the LLM response before safety filtering."""
|
||||
if not content:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
except Exception:
|
||||
return []
|
||||
raw_paths: list[str] = []
|
||||
if isinstance(parsed, dict) and isinstance(parsed.get('files'), list):
|
||||
for item in parsed['files']:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
path = str(item.get('path') or '').strip()
|
||||
if path:
|
||||
raw_paths.append(path)
|
||||
elif isinstance(parsed, dict):
|
||||
for path in parsed.keys():
|
||||
normalized_path = str(path).strip()
|
||||
if normalized_path:
|
||||
raw_paths.append(normalized_path)
|
||||
return raw_paths
|
||||
|
||||
def _update_progress(self, progress: int, step: str, message: str) -> None:
|
||||
self.progress = progress
|
||||
self.current_step = step
|
||||
@@ -781,7 +843,7 @@ class AgentOrchestrator:
|
||||
self._write_file(relative_path, content)
|
||||
if editing_existing_workspace and len(self.pending_code_changes) == change_count_before:
|
||||
raise RuntimeError('The LLM response did not produce any file changes for the existing project.')
|
||||
fallback_used = bool(trace and trace.get('fallback_used')) or trace is None
|
||||
fallback_used = bool(trace and trace.get('fallback_used'))
|
||||
if self.db_manager and self.history and self.prompt_audit and trace:
|
||||
self.db_manager.log_llm_trace(
|
||||
project_id=self.project_id,
|
||||
@@ -796,10 +858,7 @@ class AgentOrchestrator:
|
||||
raw_response=trace.get('raw_response'),
|
||||
fallback_used=fallback_used,
|
||||
)
|
||||
if fallback_used:
|
||||
self._append_log('LLM code generation was unavailable; used deterministic scaffolding fallback.')
|
||||
else:
|
||||
self._append_log('Application files generated from the prompt with the configured LLM.')
|
||||
self._append_log('Application files generated from the prompt with the configured LLM.')
|
||||
|
||||
async def _run_tests(self) -> None:
|
||||
"""Run tests for the generated code."""
|
||||
@@ -810,11 +869,25 @@ class AgentOrchestrator:
|
||||
async def _commit_to_git(self) -> None:
|
||||
"""Commit changes to git."""
|
||||
unique_files = list(dict.fromkeys(self.changed_files))
|
||||
git_debug = self.ui_manager.ui_data.setdefault('git', {})
|
||||
if not unique_files:
|
||||
git_debug.update({
|
||||
'commit_status': 'skipped',
|
||||
'early_exit_reason': 'changed_files_empty',
|
||||
'candidate_files': [],
|
||||
})
|
||||
self._append_log('Git commit skipped: no generated files were marked as changed.')
|
||||
self._log_system_debug('git', 'Commit exited early because changed_files was empty.')
|
||||
return
|
||||
if not self.git_manager.is_git_available():
|
||||
self.ui_manager.ui_data.setdefault('git', {})['error'] = 'git executable is not available in PATH'
|
||||
git_debug.update({
|
||||
'commit_status': 'error',
|
||||
'early_exit_reason': 'git_unavailable',
|
||||
'candidate_files': unique_files,
|
||||
'error': 'git executable is not available in PATH',
|
||||
})
|
||||
self._append_log('Git commit skipped: git executable is not available in PATH')
|
||||
self._log_system_debug('git', 'Commit exited early because git is unavailable.', level='ERROR')
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -822,7 +895,23 @@ class AgentOrchestrator:
|
||||
self.git_manager.init_repo()
|
||||
base_commit = self.git_manager.current_head_or_none()
|
||||
self.git_manager.add_files(unique_files)
|
||||
if not self.git_manager.get_status():
|
||||
status_after_add = self.git_manager.get_status()
|
||||
if not status_after_add:
|
||||
git_debug.update({
|
||||
'commit_status': 'skipped',
|
||||
'early_exit_reason': 'clean_after_staging',
|
||||
'candidate_files': unique_files,
|
||||
'status_after_add': '',
|
||||
})
|
||||
self._append_log(
|
||||
'Git commit skipped: working tree was clean after staging candidate files '
|
||||
f'{unique_files}. No repository diff was created.'
|
||||
)
|
||||
self._log_system_debug(
|
||||
'git',
|
||||
'Commit exited early because git status was clean after staging '
|
||||
f'files={unique_files}',
|
||||
)
|
||||
return
|
||||
|
||||
commit_message = f"AI generation for prompt: {self.project_name}"
|
||||
@@ -833,13 +922,19 @@ class AgentOrchestrator:
|
||||
"files": unique_files,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"scope": "local",
|
||||
"branch": self.branch_name,
|
||||
"branch": self._delivery_branch_name(),
|
||||
}
|
||||
git_debug.update({
|
||||
'commit_status': 'committed',
|
||||
'early_exit_reason': None,
|
||||
'candidate_files': unique_files,
|
||||
'status_after_add': status_after_add,
|
||||
})
|
||||
remote_record = None
|
||||
try:
|
||||
remote_record = await self._push_remote_commit(commit_hash, commit_message, unique_files, base_commit)
|
||||
except (RuntimeError, subprocess.CalledProcessError, FileNotFoundError) as remote_exc:
|
||||
self.ui_manager.ui_data.setdefault("git", {})["remote_error"] = str(remote_exc)
|
||||
git_debug["remote_error"] = str(remote_exc)
|
||||
self._append_log(f"Remote git push skipped: {remote_exc}")
|
||||
|
||||
if remote_record:
|
||||
@@ -849,8 +944,8 @@ class AgentOrchestrator:
|
||||
if remote_record.get('pull_request'):
|
||||
commit_record['pull_request'] = remote_record['pull_request']
|
||||
self.ui_manager.ui_data['pull_request'] = remote_record['pull_request']
|
||||
self.ui_manager.ui_data.setdefault("git", {})["latest_commit"] = commit_record
|
||||
self.ui_manager.ui_data.setdefault("git", {})["commits"] = [commit_record]
|
||||
git_debug["latest_commit"] = commit_record
|
||||
git_debug["commits"] = [commit_record]
|
||||
self._append_log(f"Recorded git commit {commit_hash[:12]} for generated files.")
|
||||
if self.db_manager:
|
||||
self.db_manager.log_commit(
|
||||
@@ -896,7 +991,12 @@ class AgentOrchestrator:
|
||||
commit_url=remote_record.get('commit_url') if remote_record else None,
|
||||
)
|
||||
except (RuntimeError, subprocess.CalledProcessError, FileNotFoundError) as exc:
|
||||
self.ui_manager.ui_data.setdefault("git", {})["error"] = str(exc)
|
||||
git_debug.update({
|
||||
'commit_status': 'error',
|
||||
'early_exit_reason': 'commit_exception',
|
||||
'candidate_files': unique_files,
|
||||
'error': str(exc),
|
||||
})
|
||||
self._append_log(f"Git commit skipped: {exc}")
|
||||
|
||||
async def _create_pr(self) -> None:
|
||||
|
||||
@@ -15,6 +15,14 @@ except ImportError:
|
||||
from agents.llm_service import LLMServiceClient
|
||||
|
||||
|
||||
class RequestInterpretationError(RuntimeError):
|
||||
"""Raised when one LLM-driven request interpretation stage fails."""
|
||||
|
||||
def __init__(self, message: str, *, trace: dict | None = None):
|
||||
super().__init__(message)
|
||||
self.trace = trace or {}
|
||||
|
||||
|
||||
class RequestInterpreter:
|
||||
"""Use Ollama to turn free-form text into a structured software request."""
|
||||
|
||||
@@ -28,9 +36,6 @@ class RequestInterpreter:
|
||||
GENERIC_PROJECT_NAME_WORDS = {
|
||||
'app', 'application', 'harness', 'platform', 'project', 'purpose', 'service', 'solution', 'suite', 'system', 'test', 'tool',
|
||||
}
|
||||
PLACEHOLDER_PROJECT_NAME_WORDS = {
|
||||
'generated project', 'new project', 'project', 'temporary name', 'temp name', 'placeholder', 'untitled project',
|
||||
}
|
||||
|
||||
def __init__(self, ollama_url: str | None = None, model: str | None = None):
|
||||
self.ollama_url = (ollama_url or settings.ollama_url).rstrip('/')
|
||||
@@ -91,47 +96,45 @@ class RequestInterpreter:
|
||||
},
|
||||
expect_json=True,
|
||||
)
|
||||
if content:
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
interpreted = self._normalize_interpreted_request(parsed, normalized)
|
||||
routing = self._normalize_routing(parsed.get('routing'), interpreted, compact_context)
|
||||
if routing.get('intent') == 'continue_project' and routing.get('project_name'):
|
||||
interpreted['name'] = routing['project_name']
|
||||
naming_trace = None
|
||||
if routing.get('intent') == 'new_project':
|
||||
interpreted, routing, naming_trace = await self._refine_new_project_identity(
|
||||
prompt_text=normalized,
|
||||
interpreted=interpreted,
|
||||
routing=routing,
|
||||
context=compact_context,
|
||||
)
|
||||
trace['routing'] = routing
|
||||
trace['context_excerpt'] = compact_context
|
||||
if naming_trace is not None:
|
||||
trace['project_naming'] = naming_trace
|
||||
return interpreted, trace
|
||||
except Exception:
|
||||
pass
|
||||
if not content:
|
||||
detail = self.llm_client.extract_error_message(trace)
|
||||
if detail:
|
||||
raise RequestInterpretationError(f'LLM request interpretation failed: {detail}', trace=trace)
|
||||
raise RequestInterpretationError('LLM request interpretation did not return a usable response.', trace=trace)
|
||||
|
||||
interpreted, routing = self._heuristic_fallback(normalized, compact_context)
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
except Exception as exc:
|
||||
raise RequestInterpretationError('LLM request interpretation did not return valid JSON.', trace=trace) from exc
|
||||
|
||||
try:
|
||||
interpreted = self._normalize_interpreted_request(parsed)
|
||||
routing = self._normalize_routing(parsed.get('routing'), interpreted, compact_context)
|
||||
except RuntimeError as exc:
|
||||
raise RequestInterpretationError(str(exc), trace=trace) from exc
|
||||
if routing.get('intent') == 'continue_project' and routing.get('project_name'):
|
||||
interpreted['name'] = routing['project_name']
|
||||
naming_trace = None
|
||||
if routing.get('intent') == 'new_project':
|
||||
constraints = await self._collect_project_identity_constraints(compact_context)
|
||||
routing['repo_name'] = self._ensure_unique_repo_name(routing.get('repo_name') or interpreted.get('name') or 'project', constraints['repo_names'])
|
||||
return interpreted, {
|
||||
'stage': 'request_interpretation',
|
||||
'provider': 'heuristic',
|
||||
'model': self.model,
|
||||
'system_prompt': system_prompt,
|
||||
'user_prompt': user_prompt,
|
||||
'assistant_response': json.dumps({'request': interpreted, 'routing': routing}),
|
||||
'raw_response': {'fallback': 'heuristic', 'llm_trace': trace.get('raw_response') if isinstance(trace, dict) else None},
|
||||
'routing': routing,
|
||||
'context_excerpt': compact_context,
|
||||
'guardrails': trace.get('guardrails') if isinstance(trace, dict) else [],
|
||||
'tool_context': trace.get('tool_context') if isinstance(trace, dict) else [],
|
||||
'fallback_used': True,
|
||||
}
|
||||
try:
|
||||
interpreted, routing, naming_trace = await self._refine_new_project_identity(
|
||||
prompt_text=normalized,
|
||||
interpreted=interpreted,
|
||||
routing=routing,
|
||||
context=compact_context,
|
||||
)
|
||||
except RequestInterpretationError as exc:
|
||||
combined_trace = dict(trace)
|
||||
combined_trace['routing'] = routing
|
||||
combined_trace['context_excerpt'] = compact_context
|
||||
if exc.trace:
|
||||
combined_trace['project_naming'] = exc.trace
|
||||
raise RequestInterpretationError(str(exc), trace=combined_trace) from exc
|
||||
trace['routing'] = routing
|
||||
trace['context_excerpt'] = compact_context
|
||||
if naming_trace is not None:
|
||||
trace['project_naming'] = naming_trace
|
||||
return interpreted, trace
|
||||
|
||||
async def _refine_new_project_identity(
|
||||
self,
|
||||
@@ -159,25 +162,25 @@ class RequestInterpreter:
|
||||
},
|
||||
expect_json=True,
|
||||
)
|
||||
if content:
|
||||
try:
|
||||
fallback_name = self._preferred_project_name_fallback(prompt_text, interpreted.get('name'))
|
||||
parsed = json.loads(content)
|
||||
project_name, repo_name = self._normalize_project_identity(
|
||||
parsed,
|
||||
fallback_name=fallback_name,
|
||||
)
|
||||
repo_name = self._ensure_unique_repo_name(repo_name, constraints['repo_names'])
|
||||
interpreted['name'] = project_name
|
||||
routing['project_name'] = project_name
|
||||
routing['repo_name'] = repo_name
|
||||
return interpreted, routing, trace
|
||||
except Exception:
|
||||
pass
|
||||
if not content:
|
||||
detail = self.llm_client.extract_error_message(trace)
|
||||
if detail:
|
||||
raise RequestInterpretationError(f'LLM project naming failed: {detail}', trace=trace)
|
||||
raise RequestInterpretationError('LLM project naming did not return a usable response.', trace=trace)
|
||||
|
||||
fallback_name = self._preferred_project_name_fallback(prompt_text, interpreted.get('name'))
|
||||
routing['project_name'] = fallback_name
|
||||
routing['repo_name'] = self._ensure_unique_repo_name(self._derive_repo_name(fallback_name), constraints['repo_names'])
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
except Exception as exc:
|
||||
raise RequestInterpretationError('LLM project naming did not return valid JSON.', trace=trace) from exc
|
||||
|
||||
try:
|
||||
project_name, repo_name = self._normalize_project_identity(parsed)
|
||||
except RuntimeError as exc:
|
||||
raise RequestInterpretationError(str(exc), trace=trace) from exc
|
||||
repo_name = self._ensure_unique_repo_name(repo_name, constraints['repo_names'])
|
||||
interpreted['name'] = project_name
|
||||
routing['project_name'] = project_name
|
||||
routing['repo_name'] = repo_name
|
||||
return interpreted, routing, trace
|
||||
|
||||
async def _collect_project_identity_constraints(self, context: dict) -> dict[str, set[str]]:
|
||||
@@ -207,17 +210,19 @@ class RequestInterpreter:
|
||||
return set()
|
||||
return {str(repo.get('name')).strip() for repo in repos if repo.get('name')}
|
||||
|
||||
def _normalize_interpreted_request(self, interpreted: dict, original_prompt: str) -> dict:
|
||||
def _normalize_interpreted_request(self, interpreted: dict) -> dict:
|
||||
"""Normalize LLM output into the required request shape."""
|
||||
request_payload = interpreted.get('request') if isinstance(interpreted.get('request'), dict) else interpreted
|
||||
name = str(interpreted.get('name') or '').strip() or self._derive_name(original_prompt)
|
||||
if isinstance(request_payload, dict):
|
||||
name = str(request_payload.get('name') or '').strip() or self._derive_name(original_prompt)
|
||||
description = str((request_payload or {}).get('description') or '').strip() or original_prompt[:255]
|
||||
features = self._normalize_list((request_payload or {}).get('features'))
|
||||
tech_stack = self._normalize_list((request_payload or {}).get('tech_stack'))
|
||||
if not features:
|
||||
features = ['core workflow based on free-form request']
|
||||
if not isinstance(request_payload, dict):
|
||||
raise RuntimeError('LLM request interpretation did not include a request object.')
|
||||
name = str(request_payload.get('name') or '').strip()
|
||||
description = str(request_payload.get('description') or '').strip()
|
||||
if not name:
|
||||
raise RuntimeError('LLM request interpretation did not provide a project name.')
|
||||
if not description:
|
||||
raise RuntimeError('LLM request interpretation did not provide a project description.')
|
||||
features = self._normalize_list(request_payload.get('features'))
|
||||
tech_stack = self._normalize_list(request_payload.get('tech_stack'))
|
||||
return {
|
||||
'name': name[:255],
|
||||
'description': description[:255],
|
||||
@@ -251,6 +256,9 @@ class RequestInterpreter:
|
||||
def _normalize_routing(self, routing: dict | None, interpreted: dict, context: dict) -> dict:
|
||||
"""Normalize routing metadata returned by the LLM."""
|
||||
routing = routing or {}
|
||||
intent = str(routing.get('intent') or '').strip()
|
||||
if intent not in {'new_project', 'continue_project'}:
|
||||
raise RuntimeError('LLM request interpretation did not provide a valid routing intent.')
|
||||
project_id = routing.get('project_id')
|
||||
project_name = routing.get('project_name')
|
||||
issue_number = routing.get('issue_number')
|
||||
@@ -259,33 +267,32 @@ class RequestInterpreter:
|
||||
elif isinstance(issue_number, str) and issue_number.isdigit():
|
||||
issue_number = int(issue_number)
|
||||
matched_project = None
|
||||
for project in context.get('projects', []):
|
||||
if project_id and project.get('project_id') == project_id:
|
||||
matched_project = project
|
||||
break
|
||||
if project_name and project.get('name') == project_name:
|
||||
matched_project = project
|
||||
break
|
||||
intent = str(routing.get('intent') or '').strip() or ('continue_project' if matched_project else 'new_project')
|
||||
if matched_project is None and intent == 'continue_project':
|
||||
recent_chat_history = context.get('recent_chat_history', [])
|
||||
recent_project_id = recent_chat_history[0].get('project_id') if recent_chat_history else None
|
||||
if recent_project_id:
|
||||
matched_project = next(
|
||||
(project for project in context.get('projects', []) if project.get('project_id') == recent_project_id),
|
||||
None,
|
||||
)
|
||||
if intent == 'continue_project':
|
||||
for project in context.get('projects', []):
|
||||
if project_id and project.get('project_id') == project_id:
|
||||
matched_project = project
|
||||
break
|
||||
if project_name and project.get('name') == project_name:
|
||||
matched_project = project
|
||||
break
|
||||
elif project_id:
|
||||
matched_project = next(
|
||||
(project for project in context.get('projects', []) if project.get('project_id') == project_id),
|
||||
None,
|
||||
)
|
||||
if intent == 'continue_project' and matched_project is None:
|
||||
raise RuntimeError('LLM selected continue_project without identifying a tracked project from prompt history.')
|
||||
if intent == 'new_project' and matched_project is not None:
|
||||
raise RuntimeError('LLM selected new_project while also pointing at an existing tracked project.')
|
||||
normalized = {
|
||||
'intent': intent,
|
||||
'project_id': matched_project.get('project_id') if matched_project else project_id,
|
||||
'project_name': matched_project.get('name') if matched_project else (project_name or interpreted.get('name')),
|
||||
'repo_name': routing.get('repo_name') if intent == 'new_project' else None,
|
||||
'repo_name': str(routing.get('repo_name') or '').strip() or None if intent == 'new_project' else None,
|
||||
'issue_number': issue_number,
|
||||
'confidence': routing.get('confidence') or ('medium' if matched_project else 'low'),
|
||||
'reasoning_summary': routing.get('reasoning_summary') or ('Matched prior project context' if matched_project else 'No strong prior project match found'),
|
||||
'confidence': routing.get('confidence') or 'medium',
|
||||
'reasoning_summary': routing.get('reasoning_summary') or '',
|
||||
}
|
||||
if normalized['intent'] == 'new_project' and not normalized['repo_name']:
|
||||
normalized['repo_name'] = self._derive_repo_name(normalized['project_name'] or interpreted.get('name') or 'Generated Project')
|
||||
return normalized
|
||||
|
||||
def _normalize_list(self, value) -> list[str]:
|
||||
@@ -295,42 +302,6 @@ class RequestInterpreter:
|
||||
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()
|
||||
quoted = re.search(r'["\']([^"\']{3,80})["\']', first_line)
|
||||
if quoted:
|
||||
return self._humanize_name(quoted.group(1))
|
||||
|
||||
noun_phrase = re.search(
|
||||
r'(?:build|create|start|make|develop|generate|design|need|want)\s+'
|
||||
r'(?:me\s+|us\s+|an?\s+|the\s+|new\s+|internal\s+|simple\s+|lightweight\s+|modern\s+|web\s+|mobile\s+)*'
|
||||
r'([a-z0-9][a-z0-9\s-]{2,80}?(?:portal|dashboard|app|application|service|tool|system|platform|api|bot|assistant|website|site|workspace|tracker|manager|harness|runner|framework|suite|pipeline|lab))\b',
|
||||
first_line,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if noun_phrase:
|
||||
return self._humanize_name(noun_phrase.group(1))
|
||||
|
||||
focused_phrase = re.search(
|
||||
r'(?:purpose\s+is\s+to\s+create\s+(?:an?\s+)?)'
|
||||
r'([a-z0-9][a-z0-9\s-]{2,80}?(?:portal|dashboard|app|application|service|tool|system|platform|api|bot|assistant|website|site|workspace|tracker|manager|harness|runner|framework|suite|pipeline|lab))\b',
|
||||
first_line,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if focused_phrase:
|
||||
return self._humanize_name(focused_phrase.group(1))
|
||||
|
||||
cleaned = re.sub(r'[^A-Za-z0-9 ]+', ' ', first_line)
|
||||
stopwords = {
|
||||
'build', 'create', 'start', 'make', 'develop', 'generate', 'design', 'need', 'want', 'please', 'for', 'our', 'with', 'that', 'this',
|
||||
'new', 'internal', 'simple', 'modern', 'web', 'mobile', 'app', 'application', 'tool', 'system',
|
||||
}
|
||||
tokens = [word for word in cleaned.split() if word and word.lower() not in stopwords]
|
||||
if tokens:
|
||||
return self._humanize_name(' '.join(tokens[:4]))
|
||||
return 'Generated Project'
|
||||
|
||||
def _humanize_name(self, raw_name: str) -> str:
|
||||
"""Normalize a candidate project name into a readable title."""
|
||||
cleaned = re.sub(r'[^A-Za-z0-9\s-]+', ' ', raw_name).strip(' -')
|
||||
@@ -407,15 +378,6 @@ class RequestInterpreter:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _preferred_project_name_fallback(self, prompt_text: str, interpreted_name: str | None) -> str:
|
||||
"""Pick the best fallback title when the earlier interpretation produced a placeholder."""
|
||||
interpreted_clean = self._humanize_name(str(interpreted_name or '').strip()) if interpreted_name else ''
|
||||
normalized_interpreted = interpreted_clean.lower()
|
||||
if normalized_interpreted and normalized_interpreted not in self.PLACEHOLDER_PROJECT_NAME_WORDS:
|
||||
if not (len(normalized_interpreted.split()) == 1 and normalized_interpreted in self.GENERIC_PROJECT_NAME_WORDS):
|
||||
return interpreted_clean
|
||||
return self._derive_name(prompt_text)
|
||||
|
||||
def _ensure_unique_repo_name(self, repo_name: str, reserved_names: set[str]) -> str:
|
||||
"""Choose a repository slug that does not collide with tracked or remote repositories."""
|
||||
base_name = self._derive_repo_name(repo_name)
|
||||
@@ -426,76 +388,41 @@ class RequestInterpreter:
|
||||
suffix += 1
|
||||
return f'{base_name}-{suffix}'
|
||||
|
||||
def _normalize_project_identity(self, payload: dict, fallback_name: str) -> tuple[str, str]:
|
||||
"""Normalize model-proposed project and repository naming."""
|
||||
fallback_project_name = self._humanize_name(str(fallback_name or 'Generated Project'))
|
||||
project_candidate = str(payload.get('project_name') or payload.get('name') or '').strip()
|
||||
project_name = fallback_project_name
|
||||
if project_candidate and self._should_use_project_name_candidate(project_candidate, fallback_project_name):
|
||||
project_name = self._humanize_name(project_candidate)
|
||||
repo_candidate = str(payload.get('repo_name') or '').strip()
|
||||
repo_name = self._derive_repo_name(project_name)
|
||||
if repo_candidate and self._should_use_repo_name_candidate(repo_candidate, project_name):
|
||||
repo_name = self._derive_repo_name(repo_candidate)
|
||||
return project_name, repo_name
|
||||
|
||||
def _heuristic_fallback(self, prompt_text: str, context: dict | None = None) -> tuple[dict, 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']
|
||||
interpreted = {
|
||||
'name': self._derive_name(prompt_text),
|
||||
'description': sentences[0][:255] if sentences else prompt_text[:255],
|
||||
'features': features,
|
||||
'tech_stack': tech_stack,
|
||||
}
|
||||
routing = self._heuristic_routing(prompt_text, context or {})
|
||||
if routing.get('project_name'):
|
||||
interpreted['name'] = routing['project_name']
|
||||
return interpreted, routing
|
||||
|
||||
def _heuristic_routing(self, prompt_text: str, context: dict) -> dict:
|
||||
"""Best-effort routing when the LLM is unavailable."""
|
||||
lowered = prompt_text.lower()
|
||||
explicit_new = any(token in lowered for token in ['new project', 'start a new project', 'create a new project', 'build a new app'])
|
||||
referenced_issue = self._extract_issue_number(prompt_text)
|
||||
recent_history = context.get('recent_chat_history', [])
|
||||
projects = context.get('projects', [])
|
||||
last_project_id = recent_history[0].get('project_id') if recent_history else None
|
||||
last_issue = ((recent_history[0].get('related_issue') or {}).get('number') if recent_history else None)
|
||||
|
||||
matched_project = None
|
||||
for project in projects:
|
||||
name = (project.get('name') or '').lower()
|
||||
repo = ((project.get('repository') or {}).get('name') or '').lower()
|
||||
if name and name in lowered:
|
||||
matched_project = project
|
||||
break
|
||||
if repo and repo in lowered:
|
||||
matched_project = project
|
||||
break
|
||||
if matched_project is None and not explicit_new:
|
||||
follow_up_tokens = ['also', 'continue', 'for this project', 'for that project', 'work on this', 'work on that', 'fix that', 'add this']
|
||||
if any(token in lowered for token in follow_up_tokens) and last_project_id:
|
||||
matched_project = next((project for project in projects if project.get('project_id') == last_project_id), None)
|
||||
issue_number = referenced_issue
|
||||
if issue_number is None and any(token in lowered for token in ['that issue', 'this issue', 'the issue']) and last_issue is not None:
|
||||
issue_number = last_issue
|
||||
intent = 'new_project' if explicit_new or matched_project is None else 'continue_project'
|
||||
return {
|
||||
'intent': intent,
|
||||
'project_id': matched_project.get('project_id') if matched_project else None,
|
||||
'project_name': matched_project.get('name') if matched_project else self._derive_name(prompt_text),
|
||||
'repo_name': None if matched_project else self._derive_repo_name(self._derive_name(prompt_text)),
|
||||
'issue_number': issue_number,
|
||||
'confidence': 'medium' if matched_project or explicit_new else 'low',
|
||||
'reasoning_summary': 'Heuristic routing from chat history and project names.',
|
||||
}
|
||||
def _normalize_project_identity(self, payload: dict) -> tuple[str, str]:
|
||||
"""Validate model-proposed project and repository naming."""
|
||||
project_payload = payload.get('project') if isinstance(payload.get('project'), dict) else {}
|
||||
repository_payload = payload.get('repository') if isinstance(payload.get('repository'), dict) else {}
|
||||
project_candidate = str(
|
||||
payload.get('project_name')
|
||||
or payload.get('name')
|
||||
or payload.get('title')
|
||||
or payload.get('display_name')
|
||||
or project_payload.get('project_name')
|
||||
or project_payload.get('name')
|
||||
or project_payload.get('title')
|
||||
or project_payload.get('display_name')
|
||||
or ''
|
||||
).strip()
|
||||
repo_candidate = str(
|
||||
payload.get('repo_name')
|
||||
or payload.get('repo')
|
||||
or payload.get('slug')
|
||||
or payload.get('repository_name')
|
||||
or payload.get('repository_slug')
|
||||
or repository_payload.get('repo_name')
|
||||
or repository_payload.get('name')
|
||||
or repository_payload.get('slug')
|
||||
or ''
|
||||
).strip()
|
||||
if not project_candidate:
|
||||
raise RuntimeError('LLM project naming did not provide a project name.')
|
||||
if not repo_candidate:
|
||||
raise RuntimeError('LLM project naming did not provide a repository slug.')
|
||||
if not self._should_use_project_name_candidate(project_candidate, project_candidate):
|
||||
raise RuntimeError('LLM project naming returned an unusable project name.')
|
||||
if not self._should_use_repo_name_candidate(repo_candidate, project_candidate):
|
||||
raise RuntimeError('LLM project naming returned an unusable repository slug.')
|
||||
return self._humanize_name(project_candidate), self._derive_repo_name(repo_candidate)
|
||||
|
||||
def _extract_issue_number(self, prompt_text: str) -> int | None:
|
||||
match = re.search(r'(?:#|issue\s+)(\d+)', prompt_text, flags=re.IGNORECASE)
|
||||
|
||||
@@ -222,6 +222,7 @@ class Settings(BaseSettings):
|
||||
# Ollama settings computed from environment
|
||||
OLLAMA_URL: str = "http://ollama:11434"
|
||||
OLLAMA_MODEL: str = "llama3"
|
||||
LLM_REQUEST_TIMEOUT_SECONDS: int = 240
|
||||
LLM_GUARDRAIL_PROMPT: str = (
|
||||
"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."
|
||||
@@ -613,6 +614,11 @@ class Settings(BaseSettings):
|
||||
"""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)
|
||||
|
||||
@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
|
||||
def projects_root(self) -> Path:
|
||||
"""Get the root directory for generated project artifacts."""
|
||||
|
||||
@@ -214,6 +214,70 @@ def _render_commit_list(commits: list[dict]) -> None:
|
||||
ui.link('Open compare view', compare_url, new_tab=True)
|
||||
|
||||
|
||||
def _render_generation_diagnostics(ui_data: dict | None) -> None:
|
||||
"""Render generation and git diagnostics from the latest UI snapshot."""
|
||||
snapshot = ui_data if isinstance(ui_data, dict) else {}
|
||||
generation_debug = snapshot.get('generation_debug') if isinstance(snapshot.get('generation_debug'), dict) else {}
|
||||
git_debug = snapshot.get('git') if isinstance(snapshot.get('git'), dict) else {}
|
||||
|
||||
if not generation_debug and not git_debug:
|
||||
ui.label('No generation diagnostics captured yet.').classes('factory-muted')
|
||||
return
|
||||
|
||||
def _render_path_row(label: str, values: list[str]) -> None:
|
||||
text = ', '.join(values) if values else 'none'
|
||||
ui.label(f'{label}: {text}').classes('factory-muted' if values else 'factory-code')
|
||||
|
||||
with ui.column().classes('gap-3 w-full'):
|
||||
if generation_debug:
|
||||
with ui.column().classes('gap-1'):
|
||||
ui.label('Generation filtering').style('font-weight: 700; color: #2f241d;')
|
||||
ui.label(
|
||||
'Existing workspace: '
|
||||
+ ('yes' if generation_debug.get('existing_workspace') else 'no')
|
||||
).classes('factory-muted')
|
||||
_render_path_row('Raw paths', generation_debug.get('raw_paths') or [])
|
||||
_render_path_row('Accepted paths', generation_debug.get('accepted_paths') or [])
|
||||
_render_path_row('Rejected paths', generation_debug.get('rejected_paths') or [])
|
||||
if git_debug:
|
||||
with ui.column().classes('gap-1'):
|
||||
ui.label('Git outcome').style('font-weight: 700; color: #2f241d;')
|
||||
if git_debug.get('commit_status'):
|
||||
with ui.row().classes('items-center gap-2'):
|
||||
ui.label(git_debug['commit_status']).classes('factory-chip')
|
||||
if git_debug.get('early_exit_reason'):
|
||||
ui.label(git_debug['early_exit_reason']).classes('factory-chip')
|
||||
if git_debug.get('candidate_files') is not None:
|
||||
_render_path_row('Candidate files', git_debug.get('candidate_files') or [])
|
||||
latest_commit = git_debug.get('latest_commit') if isinstance(git_debug.get('latest_commit'), dict) else {}
|
||||
if latest_commit:
|
||||
ui.label(
|
||||
f"Latest commit: {(latest_commit.get('hash') or 'unknown')[:12]} · {latest_commit.get('scope') or 'local'}"
|
||||
).classes('factory-muted')
|
||||
if git_debug.get('status_after_add'):
|
||||
with ui.expansion('Git status after staging').classes('w-full q-mt-sm'):
|
||||
ui.label(str(git_debug['status_after_add'])).classes('factory-code')
|
||||
if git_debug.get('remote_error'):
|
||||
ui.label(f"Remote push error: {git_debug['remote_error']}").classes('factory-code')
|
||||
if git_debug.get('error'):
|
||||
ui.label(f"Git error: {git_debug['error']}").classes('factory-code')
|
||||
pull_request_debug = git_debug.get('pull_request_debug') if isinstance(git_debug.get('pull_request_debug'), dict) else {}
|
||||
if pull_request_debug:
|
||||
ui.label('Pull request creation').style('font-weight: 700; color: #2f241d;')
|
||||
if pull_request_debug.get('status'):
|
||||
ui.label(str(pull_request_debug['status'])).classes('factory-chip')
|
||||
if pull_request_debug.get('request'):
|
||||
with ui.expansion('PR request payload').classes('w-full q-mt-sm'):
|
||||
ui.label(json.dumps(pull_request_debug['request'], indent=2, sort_keys=True)).classes('factory-code')
|
||||
if pull_request_debug.get('response'):
|
||||
with ui.expansion('PR API response').classes('w-full q-mt-sm'):
|
||||
ui.label(json.dumps(pull_request_debug['response'], indent=2, sort_keys=True)).classes('factory-code')
|
||||
if pull_request_debug.get('resolved'):
|
||||
resolved = pull_request_debug['resolved']
|
||||
if resolved.get('pr_url'):
|
||||
ui.link('Open pull request', resolved['pr_url'], new_tab=True).classes('factory-code')
|
||||
|
||||
|
||||
def _render_timeline(events: list[dict]) -> None:
|
||||
"""Render a mixed project timeline."""
|
||||
if not events:
|
||||
@@ -481,6 +545,52 @@ def _render_change_list(changes: list[dict]) -> None:
|
||||
_render_side_by_side_diff(change['diff_text'])
|
||||
|
||||
|
||||
def _extract_llm_trace_error(trace: dict) -> str | None:
|
||||
"""Extract one useful failure message from a persisted LLM trace."""
|
||||
if not isinstance(trace, dict):
|
||||
return None
|
||||
raw_response = trace.get('raw_response') if isinstance(trace.get('raw_response'), dict) else {}
|
||||
provider_trace = raw_response.get('provider_trace') if isinstance(raw_response.get('provider_trace'), dict) else {}
|
||||
provider_response = raw_response.get('provider_response') if isinstance(raw_response.get('provider_response'), dict) else {}
|
||||
nested_provider_response = provider_trace.get('provider_response') if isinstance(provider_trace.get('provider_response'), dict) else {}
|
||||
candidates = [
|
||||
raw_response.get('failure_message'),
|
||||
raw_response.get('error'),
|
||||
provider_response.get('error'),
|
||||
provider_trace.get('error'),
|
||||
nested_provider_response.get('error'),
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate:
|
||||
return str(candidate)
|
||||
return None
|
||||
|
||||
|
||||
def _build_llm_insights(traces: list[dict]) -> dict:
|
||||
"""Summarize recent LLM activity for dashboard visibility."""
|
||||
normalized = [trace for trace in traces if isinstance(trace, dict)]
|
||||
by_stage: dict[str, int] = {}
|
||||
error_by_stage: dict[str, int] = {}
|
||||
recent_errors: list[dict] = []
|
||||
fallback_traces = 0
|
||||
for trace in normalized:
|
||||
stage = str(trace.get('stage') or 'unknown')
|
||||
by_stage[stage] = by_stage.get(stage, 0) + 1
|
||||
if trace.get('fallback_used'):
|
||||
fallback_traces += 1
|
||||
if _extract_llm_trace_error(trace):
|
||||
error_by_stage[stage] = error_by_stage.get(stage, 0) + 1
|
||||
recent_errors.append(trace)
|
||||
return {
|
||||
'total_traces': len(normalized),
|
||||
'error_traces': len(recent_errors),
|
||||
'fallback_traces': fallback_traces,
|
||||
'by_stage': by_stage,
|
||||
'error_by_stage': error_by_stage,
|
||||
'recent_errors': recent_errors[:8],
|
||||
}
|
||||
|
||||
|
||||
def _render_llm_traces(traces: list[dict]) -> None:
|
||||
"""Render persisted LLM request/response traces for a prompt."""
|
||||
if not traces:
|
||||
@@ -496,12 +606,17 @@ def _render_llm_traces(traces: list[dict]) -> None:
|
||||
ui.label(f'{provider}:{model}').classes('factory-chip')
|
||||
if trace.get('fallback_used'):
|
||||
ui.label('Fallback path used').classes('factory-chip')
|
||||
error_message = _extract_llm_trace_error(trace)
|
||||
if error_message:
|
||||
ui.label(error_message).classes('factory-chip')
|
||||
with ui.expansion('System prompt').classes('w-full q-mt-sm'):
|
||||
ui.label(trace.get('system_prompt') or 'No system prompt recorded').classes('factory-code')
|
||||
with ui.expansion('User prompt').classes('w-full q-mt-sm'):
|
||||
ui.label(trace.get('user_prompt') or 'No user prompt recorded').classes('factory-code')
|
||||
with ui.expansion('Assistant response').classes('w-full q-mt-sm'):
|
||||
ui.label(trace.get('assistant_response') or 'No assistant response recorded').classes('factory-code')
|
||||
with ui.expansion('Raw provider response').classes('w-full q-mt-sm'):
|
||||
ui.label(json.dumps(trace.get('raw_response'), indent=2, sort_keys=True) if trace.get('raw_response') is not None else 'No raw response recorded').classes('factory-code')
|
||||
|
||||
|
||||
def _filter_llm_traces(traces: list[dict], stage: str, model: str, search_query: str) -> list[dict]:
|
||||
@@ -661,6 +776,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(
|
||||
@@ -757,6 +886,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()
|
||||
@@ -779,6 +909,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()
|
||||
@@ -866,7 +1023,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')
|
||||
@@ -1367,6 +1524,7 @@ def create_dashboard():
|
||||
}
|
||||
projects = snapshot['projects']
|
||||
all_llm_traces = [trace for project_bundle in projects for trace in project_bundle.get('llm_traces', [])]
|
||||
recent_llm_traces = snapshot.get('recent_llm_traces', [])
|
||||
return {
|
||||
'snapshot': snapshot,
|
||||
'summary': snapshot['summary'],
|
||||
@@ -1384,6 +1542,8 @@ def create_dashboard():
|
||||
'discovered_repositories': discovered_repositories,
|
||||
'prompt_settings': prompt_settings,
|
||||
'runtime_settings': runtime_settings,
|
||||
'recent_llm_traces': recent_llm_traces,
|
||||
'llm_insights': _build_llm_insights(recent_llm_traces),
|
||||
'llm_stage_options': [''] + sorted({trace.get('stage') for trace in all_llm_traces if trace.get('stage')}),
|
||||
'llm_model_options': [''] + sorted({trace.get('model') for trace in all_llm_traces if trace.get('model')}),
|
||||
'project_repository_map': {
|
||||
@@ -1576,6 +1736,9 @@ def create_dashboard():
|
||||
'Sync Repo Activity',
|
||||
on_click=lambda _=None, project_id=project['project_id']: sync_project_repository_action(project_id),
|
||||
).props('outline color=secondary').classes('q-mt-md')
|
||||
with ui.card().classes('q-pa-md'):
|
||||
ui.label('Generation Diagnostics').style('font-weight: 700; color: #3a281a;')
|
||||
_render_generation_diagnostics(project_bundle.get('ui_data'))
|
||||
|
||||
@ui.refreshable
|
||||
def render_archived_panel() -> None:
|
||||
@@ -1642,6 +1805,9 @@ def create_dashboard():
|
||||
with ui.card().classes('q-pa-md'):
|
||||
ui.label('Repository').style('font-weight: 700; color: #3a281a;')
|
||||
_render_repository_block(project_bundle.get('repository') or project.get('repository'))
|
||||
with ui.card().classes('q-pa-md'):
|
||||
ui.label('Generation Diagnostics').style('font-weight: 700; color: #3a281a;')
|
||||
_render_generation_diagnostics(project_bundle.get('ui_data'))
|
||||
with ui.card().classes('q-pa-md'):
|
||||
ui.label('Prompt').style('font-weight: 700; color: #3a281a;')
|
||||
prompts = project_bundle.get('prompts', [])
|
||||
@@ -1842,6 +2008,7 @@ def create_dashboard():
|
||||
return
|
||||
system_logs = view_model['system_logs']
|
||||
llm_runtime = view_model['llm_runtime']
|
||||
llm_insights = view_model.get('llm_insights', {})
|
||||
discovered_repositories = view_model['discovered_repositories']
|
||||
prompt_settings = view_model.get('prompt_settings', [])
|
||||
runtime_settings = view_model.get('runtime_settings', [])
|
||||
@@ -1859,6 +2026,7 @@ def create_dashboard():
|
||||
('Provider', llm_runtime.get('provider')),
|
||||
('Model', llm_runtime.get('model')),
|
||||
('Ollama URL', llm_runtime.get('ollama_url')),
|
||||
('Request Timeout', str(llm_runtime.get('request_timeout_seconds') or 'n/a')),
|
||||
('Tool Context Limit', str(llm_runtime.get('tool_context_limit'))),
|
||||
('Max Tool Call Rounds', str(llm_runtime.get('max_tool_call_rounds'))),
|
||||
('Live Gitea Tools Configured', 'yes' if llm_runtime.get('gitea_live_tools_configured') else 'no'),
|
||||
@@ -1895,6 +2063,25 @@ def create_dashboard():
|
||||
for label, text in system_prompts.items():
|
||||
ui.label(label.replace('_', ' ').title()).classes('factory-muted q-mt-sm')
|
||||
ui.label(text or 'Not configured').classes('factory-code')
|
||||
with ui.card().classes('factory-panel q-pa-lg'):
|
||||
ui.label('LLM Insights').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
|
||||
for label, value in [
|
||||
('Recent Traces', llm_insights.get('total_traces', 0)),
|
||||
('Recent Errors', llm_insights.get('error_traces', 0)),
|
||||
('Fallback Traces', llm_insights.get('fallback_traces', 0)),
|
||||
]:
|
||||
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 llm_insights.get('by_stage'):
|
||||
ui.label('Trace Volume By Stage').style('font-weight: 700; color: #3a281a; margin-top: 12px;')
|
||||
for stage_name, count in sorted(llm_insights.get('by_stage', {}).items()):
|
||||
error_count = llm_insights.get('error_by_stage', {}).get(stage_name, 0)
|
||||
ui.markdown(f"- **{stage_name}**: {count} trace(s), {error_count} error(s)")
|
||||
if llm_insights.get('recent_errors'):
|
||||
ui.label('Recent LLM Errors').style('font-weight: 700; color: #3a281a; margin-top: 12px;')
|
||||
for trace in llm_insights.get('recent_errors', []):
|
||||
ui.markdown(f"- **{trace.get('stage') or 'llm'}** · {trace.get('timestamp') or 'n/a'} · {escape(_extract_llm_trace_error(trace) or 'Unknown error')}")
|
||||
with ui.card().classes('factory-panel q-pa-lg'):
|
||||
ui.label('Home Assistant and Queue Settings').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
|
||||
ui.label('Keep only the Home Assistant base URL and access token in the environment. Entity ids, thresholds, and queue behavior are edited here and persisted in the database.').classes('factory-muted')
|
||||
|
||||
@@ -31,11 +31,12 @@ try:
|
||||
from .agents.change_summary import ChangeSummaryGenerator
|
||||
from .agents.database_manager import DatabaseManager
|
||||
from .agents.home_assistant import HomeAssistantAgent
|
||||
from .agents.request_interpreter import RequestInterpreter
|
||||
from .agents.request_interpreter import RequestInterpreter, RequestInterpretationError
|
||||
from .agents.llm_service import LLMServiceClient
|
||||
from .agents.orchestrator import AgentOrchestrator
|
||||
from .agents.n8n_setup import N8NSetupAgent
|
||||
from .agents.prompt_workflow import PromptWorkflowManager
|
||||
from .agents.telegram import TelegramHandler
|
||||
from .agents.ui_manager import UIManager
|
||||
from .models import ProjectHistory, ProjectLog, SystemLog
|
||||
except ImportError:
|
||||
@@ -44,11 +45,12 @@ except ImportError:
|
||||
from agents.change_summary import ChangeSummaryGenerator
|
||||
from agents.database_manager import DatabaseManager
|
||||
from agents.home_assistant import HomeAssistantAgent
|
||||
from agents.request_interpreter import RequestInterpreter
|
||||
from agents.request_interpreter import RequestInterpreter, RequestInterpretationError
|
||||
from agents.llm_service import LLMServiceClient
|
||||
from agents.orchestrator import AgentOrchestrator
|
||||
from agents.n8n_setup import N8NSetupAgent
|
||||
from agents.prompt_workflow import PromptWorkflowManager
|
||||
from agents.telegram import TelegramHandler
|
||||
from agents.ui_manager import UIManager
|
||||
from models import ProjectHistory, ProjectLog, SystemLog
|
||||
|
||||
@@ -78,6 +80,7 @@ app = FastAPI(lifespan=lifespan)
|
||||
|
||||
DbSession = Annotated[Session, Depends(database_module.get_db)]
|
||||
PROJECT_ID_PATTERN = re.compile(r"[^a-z0-9]+")
|
||||
UNASSIGNED_LLM_TRACE_PROJECT_ID = '__unassigned__'
|
||||
|
||||
|
||||
class SoftwareRequest(BaseModel):
|
||||
@@ -187,7 +190,6 @@ async def _derive_project_id_for_request(
|
||||
) -> tuple[str, dict | None]:
|
||||
"""Derive a stable project id for a newly created project."""
|
||||
reserved_ids = {str(project.get('project_id')).strip() for project in existing_projects if project.get('project_id')}
|
||||
fallback_id = _ensure_unique_identifier((prompt_routing or {}).get('project_name') or request.name, reserved_ids)
|
||||
user_prompt = (
|
||||
f"Original user prompt:\n{prompt_text}\n\n"
|
||||
f"Structured request:\n{json.dumps({'name': request.name, 'description': request.description, 'features': request.features, 'tech_stack': request.tech_stack}, indent=2)}\n\n"
|
||||
@@ -202,14 +204,19 @@ async def _derive_project_id_for_request(
|
||||
tool_context_input={'projects': existing_projects},
|
||||
expect_json=True,
|
||||
)
|
||||
if content:
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
candidate = parsed.get('project_id') or parsed.get('slug') or request.name
|
||||
return _ensure_unique_identifier(str(candidate), reserved_ids), trace
|
||||
except Exception:
|
||||
pass
|
||||
return fallback_id, trace
|
||||
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)
|
||||
except Exception as exc:
|
||||
raise RuntimeError('LLM project id naming did not return valid JSON.') from exc
|
||||
candidate = str(parsed.get('project_id') or parsed.get('slug') or '').strip()
|
||||
if not candidate:
|
||||
raise RuntimeError('LLM project id naming did not provide a project id.')
|
||||
return _ensure_unique_identifier(candidate, reserved_ids), trace
|
||||
|
||||
|
||||
def _serialize_project(history: ProjectHistory) -> dict:
|
||||
@@ -241,6 +248,74 @@ def _serialize_project_log(log: ProjectLog) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _ensure_summary_mentions_pull_request(summary_message: str, pull_request: dict | None) -> str:
|
||||
"""Append the pull request URL to chat summaries when one exists."""
|
||||
if not isinstance(pull_request, dict):
|
||||
return summary_message
|
||||
pr_url = (pull_request.get('pr_url') or '').strip()
|
||||
if not pr_url or pr_url in summary_message:
|
||||
return summary_message
|
||||
separator = '' if summary_message.endswith(('.', '!', '?')) else '.'
|
||||
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:
|
||||
"""Serialize a system log row."""
|
||||
return {
|
||||
@@ -271,6 +346,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,
|
||||
@@ -348,6 +468,8 @@ async def _run_generation(
|
||||
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['generation_debug'] = ((result.get('ui_data') or {}).get('generation_debug'))
|
||||
response_data['git_debug'] = ((result.get('ui_data') or {}).get('git'))
|
||||
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)))
|
||||
@@ -389,6 +511,7 @@ async def _run_generation(
|
||||
'logs': [log.get('message', '') for log in response_data.get('logs', []) if isinstance(log, dict)],
|
||||
}
|
||||
summary_message, summary_trace = await ChangeSummaryGenerator().summarize_with_trace(summary_context)
|
||||
summary_message = _ensure_summary_mentions_pull_request(summary_message, response_data.get('pull_request'))
|
||||
if orchestrator.db_manager and orchestrator.history and orchestrator.prompt_audit:
|
||||
orchestrator.db_manager.log_llm_trace(
|
||||
project_id=project_id,
|
||||
@@ -505,6 +628,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:
|
||||
@@ -546,6 +674,48 @@ async def _interpret_freeform_request(request: FreeformSoftwareRequest, manager:
|
||||
return SoftwareRequest(**interpreted), routing, interpretation_trace
|
||||
|
||||
|
||||
def _persist_llm_trace(manager: DatabaseManager, *, project_id: str, trace: dict, prompt_id: int | None = None, history_id: int | None = None) -> None:
|
||||
"""Persist one LLM trace payload when enough metadata is available."""
|
||||
if not isinstance(trace, dict) or not trace.get('stage'):
|
||||
return
|
||||
manager.log_llm_trace(
|
||||
project_id=project_id,
|
||||
history_id=history_id,
|
||||
prompt_id=prompt_id,
|
||||
stage=trace['stage'],
|
||||
provider=trace.get('provider') or 'unknown',
|
||||
model=trace.get('model') or 'unknown',
|
||||
system_prompt=trace.get('system_prompt') or '',
|
||||
user_prompt=trace.get('user_prompt') or '',
|
||||
assistant_response=trace.get('assistant_response') or '',
|
||||
raw_response=trace.get('raw_response'),
|
||||
fallback_used=trace.get('fallback_used', False),
|
||||
)
|
||||
|
||||
|
||||
def _persist_failed_freeform_llm_traces(manager: DatabaseManager, *, request: FreeformSoftwareRequest, error: RequestInterpretationError) -> None:
|
||||
"""Persist failed interpretation traces under an unassigned bucket for dashboard inspection."""
|
||||
trace = error.trace if isinstance(error.trace, dict) else {}
|
||||
if not trace:
|
||||
return
|
||||
base_trace = dict(trace)
|
||||
base_trace['raw_response'] = {
|
||||
'failure_message': str(error),
|
||||
'source': {'type': request.source, 'chat_id': request.chat_id, 'chat_type': request.chat_type},
|
||||
'provider_trace': trace.get('raw_response'),
|
||||
}
|
||||
_persist_llm_trace(manager, project_id=UNASSIGNED_LLM_TRACE_PROJECT_ID, trace=base_trace)
|
||||
naming_trace = trace.get('project_naming') if isinstance(trace.get('project_naming'), dict) else None
|
||||
if naming_trace:
|
||||
enriched_naming_trace = dict(naming_trace)
|
||||
enriched_naming_trace['raw_response'] = {
|
||||
'failure_message': str(error),
|
||||
'source': {'type': request.source, 'chat_id': request.chat_id, 'chat_type': request.chat_type},
|
||||
'provider_trace': naming_trace.get('raw_response'),
|
||||
}
|
||||
_persist_llm_trace(manager, project_id=UNASSIGNED_LLM_TRACE_PROJECT_ID, trace=enriched_naming_trace)
|
||||
|
||||
|
||||
async def _run_freeform_generation(
|
||||
request: FreeformSoftwareRequest,
|
||||
db: Session,
|
||||
@@ -576,33 +746,21 @@ async def _run_freeform_generation(
|
||||
manager = DatabaseManager(db)
|
||||
prompts = manager.get_prompt_events(project_id=project_data.get('project_id'))
|
||||
prompt_id = prompts[0]['id'] if prompts else None
|
||||
manager.log_llm_trace(
|
||||
_persist_llm_trace(
|
||||
manager,
|
||||
project_id=project_data.get('project_id'),
|
||||
history_id=project_data.get('history_id'),
|
||||
prompt_id=prompt_id,
|
||||
stage=interpretation_trace['stage'],
|
||||
provider=interpretation_trace['provider'],
|
||||
model=interpretation_trace['model'],
|
||||
system_prompt=interpretation_trace['system_prompt'],
|
||||
user_prompt=interpretation_trace['user_prompt'],
|
||||
assistant_response=interpretation_trace['assistant_response'],
|
||||
raw_response=interpretation_trace.get('raw_response'),
|
||||
fallback_used=interpretation_trace.get('fallback_used', False),
|
||||
trace=interpretation_trace,
|
||||
)
|
||||
naming_trace = interpretation_trace.get('project_naming')
|
||||
if naming_trace:
|
||||
manager.log_llm_trace(
|
||||
_persist_llm_trace(
|
||||
manager,
|
||||
project_id=project_data.get('project_id'),
|
||||
history_id=project_data.get('history_id'),
|
||||
prompt_id=prompt_id,
|
||||
stage=naming_trace['stage'],
|
||||
provider=naming_trace['provider'],
|
||||
model=naming_trace['model'],
|
||||
system_prompt=naming_trace['system_prompt'],
|
||||
user_prompt=naming_trace['user_prompt'],
|
||||
assistant_response=naming_trace['assistant_response'],
|
||||
raw_response=naming_trace.get('raw_response'),
|
||||
fallback_used=naming_trace.get('fallback_used', False),
|
||||
trace=naming_trace,
|
||||
)
|
||||
response['interpreted_request'] = structured_request.model_dump()
|
||||
response['routing'] = routing
|
||||
@@ -622,6 +780,11 @@ async def _run_freeform_generation(
|
||||
},
|
||||
)
|
||||
return response
|
||||
except RequestInterpretationError as exc:
|
||||
_persist_failed_freeform_llm_traces(manager, request=request, error=exc)
|
||||
if queue_item_id is not None:
|
||||
DatabaseManager(db).fail_queued_prompt(queue_item_id, str(exc))
|
||||
raise
|
||||
except Exception as exc:
|
||||
if queue_item_id is not None:
|
||||
DatabaseManager(db).fail_queued_prompt(queue_item_id, str(exc))
|
||||
@@ -664,6 +827,7 @@ async def _process_prompt_queue_batch(limit: int = 1, force: bool = False) -> di
|
||||
process_now=True,
|
||||
)
|
||||
response = await _run_freeform_generation(request, work_db, queue_item_id=claimed['id'])
|
||||
await _notify_telegram_queue_result(request, response=response)
|
||||
processed.append(
|
||||
{
|
||||
'queue_item_id': claimed['id'],
|
||||
@@ -673,6 +837,7 @@ async def _process_prompt_queue_batch(limit: int = 1, force: bool = False) -> di
|
||||
)
|
||||
except Exception as 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)})
|
||||
finally:
|
||||
work_db.close()
|
||||
@@ -788,6 +953,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(),
|
||||
},
|
||||
@@ -861,7 +1027,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')
|
||||
@@ -883,7 +1057,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)
|
||||
queue_item = manager.enqueue_prompt(
|
||||
prompt_text=request.prompt_text,
|
||||
@@ -892,12 +1066,19 @@ async def generate_software_from_text(request: FreeformSoftwareRequest, db: DbSe
|
||||
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 {
|
||||
'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_summary': manager.get_prompt_queue_summary(),
|
||||
'queue_gate': await _get_queue_gate_status(force=False),
|
||||
'queue_gate': queue_gate,
|
||||
'source': {
|
||||
'type': request.source,
|
||||
'chat_id': request.chat_id,
|
||||
@@ -905,7 +1086,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