Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94c38359c7 | |||
| 2943fc79ab | |||
| 3e40338bbf | |||
| 39f9651236 | |||
| 3175c53504 | |||
| 29cf2aa6bd |
31
HISTORY.md
31
HISTORY.md
@@ -4,6 +4,37 @@ Changelog
|
||||
|
||||
(unreleased)
|
||||
------------
|
||||
|
||||
Fix
|
||||
~~~
|
||||
- Better home assistant integration, refs NOISSUE. [Simon Diesenreiter]
|
||||
|
||||
|
||||
0.9.2 (2026-04-11)
|
||||
------------------
|
||||
|
||||
Fix
|
||||
~~~
|
||||
- UI improvements and prompt hardening, refs NOISSUE. [Simon
|
||||
Diesenreiter]
|
||||
|
||||
Other
|
||||
~~~~~
|
||||
|
||||
|
||||
0.9.1 (2026-04-11)
|
||||
------------------
|
||||
|
||||
Fix
|
||||
~~~
|
||||
- Better repo name generation, refs NOISSUE. [Simon Diesenreiter]
|
||||
|
||||
Other
|
||||
~~~~~
|
||||
|
||||
|
||||
0.9.0 (2026-04-11)
|
||||
------------------
|
||||
- Feat: editable guardrails, refs NOISSUE. [Simon Diesenreiter]
|
||||
|
||||
|
||||
|
||||
19
README.md
19
README.md
@@ -71,18 +71,11 @@ N8N_WEBHOOK_URL=http://n8n.yourserver.com/webhook/telegram
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
|
||||
TELEGRAM_CHAT_ID=your_chat_id
|
||||
|
||||
# Optional: queue Telegram prompts until Home Assistant reports battery/surplus targets are met.
|
||||
PROMPT_QUEUE_ENABLED=false
|
||||
PROMPT_QUEUE_AUTO_PROCESS=true
|
||||
PROMPT_QUEUE_FORCE_PROCESS=false
|
||||
PROMPT_QUEUE_POLL_INTERVAL_SECONDS=60
|
||||
PROMPT_QUEUE_MAX_BATCH_SIZE=1
|
||||
# Optional: Home Assistant integration.
|
||||
# Only the base URL and token are required in the environment.
|
||||
# Entity ids, thresholds, and queue behavior can be configured from the dashboard System tab and are stored in the database.
|
||||
HOME_ASSISTANT_URL=http://homeassistant.local:8123
|
||||
HOME_ASSISTANT_TOKEN=your_home_assistant_long_lived_token
|
||||
HOME_ASSISTANT_BATTERY_ENTITY_ID=sensor.home_battery_soc
|
||||
HOME_ASSISTANT_SURPLUS_ENTITY_ID=sensor.home_pv_surplus_power
|
||||
HOME_ASSISTANT_BATTERY_FULL_THRESHOLD=95
|
||||
HOME_ASSISTANT_SURPLUS_THRESHOLD_WATTS=100
|
||||
```
|
||||
|
||||
### Build and Run
|
||||
@@ -107,7 +100,7 @@ docker-compose up -d
|
||||
|
||||
The backend now interprets free-form Telegram text with Ollama before generation.
|
||||
If `TELEGRAM_CHAT_ID` is set, the Telegram-trigger workflow only reacts to messages from that specific chat.
|
||||
If `PROMPT_QUEUE_ENABLED=true`, Telegram prompts are stored in a durable queue and processed only when the Home Assistant battery and surplus thresholds are satisfied, unless you force processing via `/queue/process` or send `process_now=true`.
|
||||
If queueing is enabled from the dashboard System tab, Telegram prompts are stored in a durable queue and processed only when the configured Home Assistant battery and surplus thresholds are satisfied, unless you force processing via `/queue/process` or send `process_now=true`.
|
||||
|
||||
2. **Monitor progress via Web UI:**
|
||||
|
||||
@@ -121,7 +114,9 @@ If you deploy the container with PostgreSQL environment variables set, the servi
|
||||
|
||||
The health tab now shows separate application, n8n, Gitea, and Home Assistant/queue diagnostics so misconfigured integrations are visible without checking container logs.
|
||||
|
||||
The dashboard Health tab also exposes operator controls for the prompt queue, including manual batch processing, forced processing, and retrying failed items.
|
||||
The dashboard Health tab exposes operator controls for the prompt queue, including manual batch processing, forced processing, and retrying failed items.
|
||||
|
||||
The dashboard System tab now also stores Home Assistant entity ids, queue toggles, thresholds, and batch settings in the database, so the environment only needs `HOME_ASSISTANT_URL` and `HOME_ASSISTANT_TOKEN` for that integration.
|
||||
|
||||
Guardrail and system prompts are no longer environment-only in practice: the factory can persist DB-backed overrides for the editable LLM prompt set, expose them at `/llm/prompts`, and edit them from the dashboard System tab. Environment values still act as defaults and as the reset target.
|
||||
|
||||
|
||||
@@ -43,18 +43,10 @@ TELEGRAM_BOT_TOKEN=your_telegram_bot_token
|
||||
TELEGRAM_CHAT_ID=your_chat_id
|
||||
|
||||
# Home Assistant energy gate for queued Telegram prompts
|
||||
# Leave PROMPT_QUEUE_ENABLED=false to preserve immediate Telegram processing.
|
||||
PROMPT_QUEUE_ENABLED=false
|
||||
PROMPT_QUEUE_AUTO_PROCESS=true
|
||||
PROMPT_QUEUE_FORCE_PROCESS=false
|
||||
PROMPT_QUEUE_POLL_INTERVAL_SECONDS=60
|
||||
PROMPT_QUEUE_MAX_BATCH_SIZE=1
|
||||
# Only the base URL and token are environment-backed.
|
||||
# Queue toggles, entity ids, thresholds, and batch sizing can be edited in the dashboard System tab and are stored in the database.
|
||||
HOME_ASSISTANT_URL=http://homeassistant.local:8123
|
||||
HOME_ASSISTANT_TOKEN=your_home_assistant_long_lived_token
|
||||
HOME_ASSISTANT_BATTERY_ENTITY_ID=sensor.home_battery_soc
|
||||
HOME_ASSISTANT_SURPLUS_ENTITY_ID=sensor.home_pv_surplus_power
|
||||
HOME_ASSISTANT_BATTERY_FULL_THRESHOLD=95
|
||||
HOME_ASSISTANT_SURPLUS_THRESHOLD_WATTS=100
|
||||
|
||||
# PostgreSQL
|
||||
# In production, provide PostgreSQL settings below. They now take precedence over the SQLite default.
|
||||
|
||||
@@ -75,18 +75,11 @@ N8N_WEBHOOK_URL=http://n8n.yourserver.com/webhook/telegram
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
|
||||
TELEGRAM_CHAT_ID=your_chat_id
|
||||
|
||||
# Optional: queue Telegram prompts until Home Assistant reports energy surplus.
|
||||
PROMPT_QUEUE_ENABLED=false
|
||||
PROMPT_QUEUE_AUTO_PROCESS=true
|
||||
PROMPT_QUEUE_FORCE_PROCESS=false
|
||||
PROMPT_QUEUE_POLL_INTERVAL_SECONDS=60
|
||||
PROMPT_QUEUE_MAX_BATCH_SIZE=1
|
||||
# Optional: Home Assistant integration.
|
||||
# Only the base URL and token are required in the environment.
|
||||
# Entity ids, thresholds, and queue behavior can be configured from the dashboard System tab and are stored in the database.
|
||||
HOME_ASSISTANT_URL=http://homeassistant.local:8123
|
||||
HOME_ASSISTANT_TOKEN=your_home_assistant_long_lived_token
|
||||
HOME_ASSISTANT_BATTERY_ENTITY_ID=sensor.home_battery_soc
|
||||
HOME_ASSISTANT_SURPLUS_ENTITY_ID=sensor.home_pv_surplus_power
|
||||
HOME_ASSISTANT_BATTERY_FULL_THRESHOLD=95
|
||||
HOME_ASSISTANT_SURPLUS_THRESHOLD_WATTS=100
|
||||
```
|
||||
|
||||
### Build and Run
|
||||
@@ -109,7 +102,9 @@ docker-compose up -d
|
||||
Features: user authentication, task CRUD, notifications
|
||||
```
|
||||
|
||||
If `PROMPT_QUEUE_ENABLED=true`, Telegram prompts are queued durably and processed only when Home Assistant reports the configured battery and surplus thresholds. Operators can override the gate via `/queue/process` or by sending `process_now=true` to `/generate/text`.
|
||||
If queueing is enabled from the dashboard System tab, Telegram prompts are queued durably and processed only when Home Assistant reports the configured battery and surplus thresholds. Operators can override the gate via `/queue/process` or by sending `process_now=true` to `/generate/text`.
|
||||
|
||||
The dashboard System tab stores Home Assistant entity ids, queue toggles, thresholds, and batch settings in the database, so the environment only needs `HOME_ASSISTANT_URL` and `HOME_ASSISTANT_TOKEN` for that integration.
|
||||
|
||||
2. **Monitor progress via Web UI:**
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.9.0
|
||||
0.9.3
|
||||
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
try:
|
||||
from ..config import EDITABLE_LLM_PROMPTS, settings
|
||||
from ..config import EDITABLE_LLM_PROMPTS, EDITABLE_RUNTIME_SETTINGS, settings
|
||||
from ..models import (
|
||||
AuditTrail,
|
||||
ProjectHistory,
|
||||
@@ -18,7 +18,7 @@ try:
|
||||
UserAction,
|
||||
)
|
||||
except ImportError:
|
||||
from config import EDITABLE_LLM_PROMPTS, settings
|
||||
from config import EDITABLE_LLM_PROMPTS, EDITABLE_RUNTIME_SETTINGS, settings
|
||||
from models import (
|
||||
AuditTrail,
|
||||
ProjectHistory,
|
||||
@@ -87,6 +87,8 @@ class DatabaseManager:
|
||||
PROMPT_QUEUE_ACTION = 'PROMPT_QUEUED'
|
||||
PROMPT_CONFIG_PROJECT_ID = '__llm_prompt_config__'
|
||||
PROMPT_CONFIG_ACTION = 'LLM_PROMPT_CONFIG'
|
||||
RUNTIME_SETTINGS_PROJECT_ID = '__runtime_settings__'
|
||||
RUNTIME_SETTINGS_ACTION = 'RUNTIME_SETTING'
|
||||
|
||||
def __init__(self, db: Session):
|
||||
"""Initialize database manager."""
|
||||
@@ -122,6 +124,22 @@ class DatabaseManager:
|
||||
sanitized = sanitized.replace('--', '-')
|
||||
return sanitized.strip('-') or 'external-project'
|
||||
|
||||
@staticmethod
|
||||
def _partition_code_changes(raw_code_changes: list[dict], commits: list[dict]) -> tuple[list[dict], list[dict]]:
|
||||
"""Split code changes into visible committed rows and orphaned rows."""
|
||||
committed_hashes = {commit.get('commit_hash') for commit in commits if commit.get('commit_hash')}
|
||||
committed_prompt_ids = {commit.get('prompt_id') for commit in commits if commit.get('prompt_id') is not None}
|
||||
visible_changes: list[dict] = []
|
||||
orphaned_changes: list[dict] = []
|
||||
for change in raw_code_changes:
|
||||
change_commit_hash = change.get('commit_hash')
|
||||
prompt_id = change.get('prompt_id')
|
||||
if (change_commit_hash and change_commit_hash in committed_hashes) or (prompt_id is not None and prompt_id in committed_prompt_ids):
|
||||
visible_changes.append(change)
|
||||
else:
|
||||
orphaned_changes.append(change)
|
||||
return visible_changes, orphaned_changes
|
||||
|
||||
def get_project_by_repository(self, owner: str, repo_name: str, include_archived: bool = False) -> ProjectHistory | None:
|
||||
"""Return the project currently associated with a repository."""
|
||||
normalized_owner = (owner or '').strip().lower()
|
||||
@@ -464,6 +482,26 @@ class DatabaseManager:
|
||||
entries[key] = audit
|
||||
return entries
|
||||
|
||||
def _latest_runtime_setting_entries(self) -> dict[str, AuditTrail]:
|
||||
"""Return the most recent persisted audit row for each editable runtime setting key."""
|
||||
entries: dict[str, AuditTrail] = {}
|
||||
try:
|
||||
audits = (
|
||||
self.db.query(AuditTrail)
|
||||
.filter(AuditTrail.action == self.RUNTIME_SETTINGS_ACTION)
|
||||
.order_by(AuditTrail.created_at.desc(), AuditTrail.id.desc())
|
||||
.all()
|
||||
)
|
||||
except Exception:
|
||||
return entries
|
||||
for audit in audits:
|
||||
metadata = self._normalize_metadata(audit.metadata_json)
|
||||
key = str(metadata.get('key') or '').strip()
|
||||
if not key or key in entries or key not in EDITABLE_RUNTIME_SETTINGS:
|
||||
continue
|
||||
entries[key] = audit
|
||||
return entries
|
||||
|
||||
def get_llm_prompt_override(self, key: str) -> str | None:
|
||||
"""Return the persisted override for one editable LLM prompt key."""
|
||||
entry = self._latest_llm_prompt_config_entries().get(key)
|
||||
@@ -477,6 +515,16 @@ class DatabaseManager:
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
def get_runtime_setting_override(self, key: str):
|
||||
"""Return the persisted override for one editable runtime setting key."""
|
||||
entry = self._latest_runtime_setting_entries().get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
metadata = self._normalize_metadata(entry.metadata_json)
|
||||
if metadata.get('reset_to_default'):
|
||||
return None
|
||||
return metadata.get('value')
|
||||
|
||||
def get_llm_prompt_settings(self) -> list[dict]:
|
||||
"""Return editable LLM prompt definitions merged with persisted DB overrides."""
|
||||
latest = self._latest_llm_prompt_config_entries()
|
||||
@@ -502,6 +550,32 @@ class DatabaseManager:
|
||||
)
|
||||
return items
|
||||
|
||||
def get_runtime_settings(self) -> list[dict]:
|
||||
"""Return editable runtime settings merged with persisted DB overrides."""
|
||||
latest = self._latest_runtime_setting_entries()
|
||||
items = []
|
||||
for key, metadata in EDITABLE_RUNTIME_SETTINGS.items():
|
||||
entry = latest.get(key)
|
||||
entry_metadata = self._normalize_metadata(entry.metadata_json) if entry is not None else {}
|
||||
default_value = getattr(settings, key)
|
||||
persisted_value = None if entry_metadata.get('reset_to_default') else entry_metadata.get('value')
|
||||
items.append(
|
||||
{
|
||||
'key': key,
|
||||
'label': metadata['label'],
|
||||
'category': metadata['category'],
|
||||
'description': metadata['description'],
|
||||
'value_type': metadata['value_type'],
|
||||
'default_value': default_value,
|
||||
'value': persisted_value if persisted_value is not None else default_value,
|
||||
'source': 'database' if persisted_value is not None else 'environment',
|
||||
'updated_at': entry.created_at.isoformat() if entry and entry.created_at else None,
|
||||
'updated_by': entry.actor if entry is not None else None,
|
||||
'reset_to_default': bool(entry_metadata.get('reset_to_default')) if entry is not None else False,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
def save_llm_prompt_setting(self, key: str, value: str, actor: str = 'dashboard') -> dict:
|
||||
"""Persist one editable LLM prompt override into the audit trail."""
|
||||
if key not in EDITABLE_LLM_PROMPTS:
|
||||
@@ -524,6 +598,28 @@ class DatabaseManager:
|
||||
self.db.refresh(audit)
|
||||
return {'status': 'success', 'setting': next(item for item in self.get_llm_prompt_settings() if item['key'] == key)}
|
||||
|
||||
def save_runtime_setting(self, key: str, value, actor: str = 'dashboard') -> dict:
|
||||
"""Persist one editable runtime setting override into the audit trail."""
|
||||
if key not in EDITABLE_RUNTIME_SETTINGS:
|
||||
return {'status': 'error', 'message': f'Unsupported runtime setting key: {key}'}
|
||||
audit = AuditTrail(
|
||||
project_id=self.RUNTIME_SETTINGS_PROJECT_ID,
|
||||
action=self.RUNTIME_SETTINGS_ACTION,
|
||||
actor=actor,
|
||||
action_type='UPDATE',
|
||||
details=f'Updated runtime setting {key}',
|
||||
message=f'Updated runtime setting {key}',
|
||||
metadata_json={
|
||||
'key': key,
|
||||
'value': value,
|
||||
'reset_to_default': False,
|
||||
},
|
||||
)
|
||||
self.db.add(audit)
|
||||
self.db.commit()
|
||||
self.db.refresh(audit)
|
||||
return {'status': 'success', 'setting': next(item for item in self.get_runtime_settings() if item['key'] == key)}
|
||||
|
||||
def reset_llm_prompt_setting(self, key: str, actor: str = 'dashboard') -> dict:
|
||||
"""Reset one editable LLM prompt override back to its environment/default value."""
|
||||
if key not in EDITABLE_LLM_PROMPTS:
|
||||
@@ -546,6 +642,28 @@ class DatabaseManager:
|
||||
self.db.refresh(audit)
|
||||
return {'status': 'success', 'setting': next(item for item in self.get_llm_prompt_settings() if item['key'] == key)}
|
||||
|
||||
def reset_runtime_setting(self, key: str, actor: str = 'dashboard') -> dict:
|
||||
"""Reset one editable runtime setting override back to its environment/default value."""
|
||||
if key not in EDITABLE_RUNTIME_SETTINGS:
|
||||
return {'status': 'error', 'message': f'Unsupported runtime setting key: {key}'}
|
||||
audit = AuditTrail(
|
||||
project_id=self.RUNTIME_SETTINGS_PROJECT_ID,
|
||||
action=self.RUNTIME_SETTINGS_ACTION,
|
||||
actor=actor,
|
||||
action_type='RESET',
|
||||
details=f'Reset runtime setting {key} to default',
|
||||
message=f'Reset runtime setting {key} to default',
|
||||
metadata_json={
|
||||
'key': key,
|
||||
'value': None,
|
||||
'reset_to_default': True,
|
||||
},
|
||||
)
|
||||
self.db.add(audit)
|
||||
self.db.commit()
|
||||
self.db.refresh(audit)
|
||||
return {'status': 'success', 'setting': next(item for item in self.get_runtime_settings() if item['key'] == key)}
|
||||
|
||||
def attach_issue_to_prompt(self, prompt_id: int, related_issue: dict) -> AuditTrail | None:
|
||||
"""Attach resolved issue context to a previously recorded prompt."""
|
||||
prompt = self.db.query(AuditTrail).filter(AuditTrail.id == prompt_id, AuditTrail.action == 'PROMPT_RECEIVED').first()
|
||||
@@ -1423,7 +1541,9 @@ class DatabaseManager:
|
||||
def log_code_change(self, project_id: str, change_type: str, file_path: str,
|
||||
actor: str, actor_type: str, details: str,
|
||||
history_id: int | None = None, prompt_id: int | None = None,
|
||||
diff_summary: str | None = None, diff_text: str | None = None) -> AuditTrail:
|
||||
diff_summary: str | None = None, diff_text: str | None = None,
|
||||
commit_hash: str | None = None, remote_status: str | None = None,
|
||||
branch: str | None = None) -> AuditTrail:
|
||||
"""Log a code change."""
|
||||
audit = AuditTrail(
|
||||
project_id=project_id,
|
||||
@@ -1442,6 +1562,9 @@ class DatabaseManager:
|
||||
"details": details,
|
||||
"diff_summary": diff_summary,
|
||||
"diff_text": diff_text,
|
||||
"commit_hash": commit_hash,
|
||||
"remote_status": remote_status,
|
||||
"branch": branch,
|
||||
}
|
||||
)
|
||||
self.db.add(audit)
|
||||
@@ -2132,16 +2255,29 @@ class DatabaseManager:
|
||||
).order_by(AuditTrail.created_at.desc()).all()
|
||||
|
||||
prompts = self.get_prompt_events(project_id=project_id)
|
||||
code_changes = self.get_code_changes(project_id=project_id)
|
||||
raw_code_changes = self.get_code_changes(project_id=project_id)
|
||||
commits = self.get_commits(project_id=project_id)
|
||||
pull_requests = self.get_pull_requests(project_id=project_id)
|
||||
llm_traces = self.get_llm_traces(project_id=project_id)
|
||||
correlations = self.get_prompt_change_correlations(project_id=project_id)
|
||||
code_changes, orphan_code_changes = self._partition_code_changes(raw_code_changes, commits)
|
||||
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)
|
||||
issues = self.get_repository_issues(project_id=project_id)
|
||||
issue_work = self.get_issue_work_events(project_id=project_id)
|
||||
if orphan_code_changes:
|
||||
delivery_status = 'uncommitted'
|
||||
delivery_message = (
|
||||
f"{len(orphan_code_changes)} generated file change(s) were recorded without a matching git commit. "
|
||||
"These changes never reached a PR-backed delivery."
|
||||
)
|
||||
elif commits:
|
||||
delivery_status = 'delivered'
|
||||
delivery_message = 'Generated changes were recorded in git commits for this project.'
|
||||
else:
|
||||
delivery_status = 'pending'
|
||||
delivery_message = 'No git commit has been recorded for this project yet.'
|
||||
|
||||
return {
|
||||
"project": {
|
||||
@@ -2157,6 +2293,9 @@ class DatabaseManager:
|
||||
"repository": repository,
|
||||
"repository_sync": repository_sync,
|
||||
"open_pull_requests": len([pr for pr in pull_requests if pr["pr_state"] == "open" and not pr["merged"]]),
|
||||
"delivery_status": delivery_status,
|
||||
"delivery_message": delivery_message,
|
||||
"orphan_code_change_count": len(orphan_code_changes),
|
||||
"completed_at": history.completed_at.isoformat() if history.completed_at else None,
|
||||
"created_at": history.started_at.isoformat() if history.started_at else None
|
||||
},
|
||||
@@ -2195,6 +2334,7 @@ class DatabaseManager:
|
||||
],
|
||||
"prompts": prompts,
|
||||
"code_changes": code_changes,
|
||||
"orphan_code_changes": orphan_code_changes,
|
||||
"commits": commits,
|
||||
"pull_requests": pull_requests,
|
||||
"llm_traces": llm_traces,
|
||||
@@ -2249,6 +2389,9 @@ class DatabaseManager:
|
||||
"history_id": self._normalize_metadata(change.metadata_json).get("history_id"),
|
||||
"diff_summary": self._normalize_metadata(change.metadata_json).get("diff_summary"),
|
||||
"diff_text": self._normalize_metadata(change.metadata_json).get("diff_text"),
|
||||
"commit_hash": self._normalize_metadata(change.metadata_json).get("commit_hash"),
|
||||
"remote_status": self._normalize_metadata(change.metadata_json).get("remote_status"),
|
||||
"branch": self._normalize_metadata(change.metadata_json).get("branch"),
|
||||
"timestamp": change.created_at.isoformat() if change.created_at else None,
|
||||
}
|
||||
for change in changes
|
||||
@@ -2258,8 +2401,9 @@ class DatabaseManager:
|
||||
"""Correlate prompts with the concrete code changes that followed them."""
|
||||
correlations = self._build_correlations_from_links(project_id=project_id, limit=limit)
|
||||
if correlations:
|
||||
return correlations
|
||||
return self._build_correlations_from_audit_fallback(project_id=project_id, limit=limit)
|
||||
return [correlation for correlation in correlations if correlation.get('commits')]
|
||||
fallback = self._build_correlations_from_audit_fallback(project_id=project_id, limit=limit)
|
||||
return [correlation for correlation in fallback if correlation.get('commits')]
|
||||
|
||||
def get_dashboard_snapshot(self, limit: int = 8) -> dict:
|
||||
"""Return DB-backed dashboard data for the UI."""
|
||||
@@ -2282,7 +2426,10 @@ class DatabaseManager:
|
||||
pass
|
||||
active_projects = self.get_all_projects()
|
||||
archived_projects = self.get_all_projects(archived_only=True)
|
||||
projects = active_projects[:limit]
|
||||
project_bundles = [self.get_project_audit_data(project.project_id) for project in active_projects[:limit]]
|
||||
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)
|
||||
system_logs = self.db.query(SystemLog).order_by(SystemLog.created_at.desc()).limit(limit).all()
|
||||
return {
|
||||
"summary": {
|
||||
@@ -2294,13 +2441,14 @@ class DatabaseManager:
|
||||
"prompt_events": self.db.query(AuditTrail).filter(AuditTrail.action == "PROMPT_RECEIVED").count(),
|
||||
"queued_prompts": queue_summary.get('queued', 0),
|
||||
"failed_queued_prompts": queue_summary.get('failed', 0),
|
||||
"code_changes": self.db.query(AuditTrail).filter(AuditTrail.action == "CODE_CHANGE").count(),
|
||||
"code_changes": sum(len(bundle.get('code_changes', [])) for bundle in all_project_bundles),
|
||||
"orphan_code_changes": sum(len(bundle.get('orphan_code_changes', [])) for bundle in all_project_bundles),
|
||||
"open_pull_requests": self.db.query(PullRequest).filter(PullRequest.pr_state == "open", PullRequest.merged.is_(False)).count(),
|
||||
"tracked_issues": self.db.query(AuditTrail).filter(AuditTrail.action == "REPOSITORY_ISSUE").count(),
|
||||
"issue_work_events": self.db.query(AuditTrail).filter(AuditTrail.action == "ISSUE_WORKED").count(),
|
||||
},
|
||||
"projects": [self.get_project_audit_data(project.project_id) for project in projects],
|
||||
"archived_projects": [self.get_project_audit_data(project.project_id) for project in archived_projects[:limit]],
|
||||
"projects": project_bundles,
|
||||
"archived_projects": archived_project_bundles,
|
||||
"system_logs": [
|
||||
{
|
||||
"id": log.id,
|
||||
@@ -2319,6 +2467,61 @@ class DatabaseManager:
|
||||
},
|
||||
}
|
||||
|
||||
def cleanup_orphan_code_changes(self, project_id: str | None = None) -> dict:
|
||||
"""Delete code change rows that cannot be tied to any recorded commit."""
|
||||
change_query = self.db.query(AuditTrail).filter(AuditTrail.action == 'CODE_CHANGE')
|
||||
commit_query = self.db.query(AuditTrail).filter(AuditTrail.action == 'GIT_COMMIT')
|
||||
if project_id:
|
||||
change_query = change_query.filter(AuditTrail.project_id == project_id)
|
||||
commit_query = commit_query.filter(AuditTrail.project_id == project_id)
|
||||
|
||||
change_rows = change_query.all()
|
||||
commit_rows = commit_query.all()
|
||||
commits = [
|
||||
{
|
||||
'commit_hash': self._normalize_metadata(commit.metadata_json).get('commit_hash'),
|
||||
'prompt_id': self._normalize_metadata(commit.metadata_json).get('prompt_id'),
|
||||
}
|
||||
for commit in commit_rows
|
||||
]
|
||||
raw_code_changes = [
|
||||
{
|
||||
'id': change.id,
|
||||
'project_id': change.project_id,
|
||||
'prompt_id': self._normalize_metadata(change.metadata_json).get('prompt_id'),
|
||||
'commit_hash': self._normalize_metadata(change.metadata_json).get('commit_hash'),
|
||||
}
|
||||
for change in change_rows
|
||||
]
|
||||
_, orphaned_changes = self._partition_code_changes(raw_code_changes, commits)
|
||||
orphan_ids = [change['id'] for change in orphaned_changes]
|
||||
orphan_projects = sorted({change['project_id'] for change in orphaned_changes if change.get('project_id')})
|
||||
|
||||
if orphan_ids:
|
||||
self.db.query(PromptCodeLink).filter(PromptCodeLink.code_change_audit_id.in_(orphan_ids)).delete(synchronize_session=False)
|
||||
self.db.query(AuditTrail).filter(AuditTrail.id.in_(orphan_ids)).delete(synchronize_session=False)
|
||||
self.db.commit()
|
||||
self.log_system_event(
|
||||
component='audit',
|
||||
level='INFO',
|
||||
message=(
|
||||
f"Purged {len(orphan_ids)} orphaned code change audit row(s)"
|
||||
+ (f" for project {project_id}" if project_id else '')
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'deleted_count': len(orphan_ids),
|
||||
'project_count': len(orphan_projects),
|
||||
'projects': orphan_projects,
|
||||
'project_id': project_id,
|
||||
'message': (
|
||||
f"Purged {len(orphan_ids)} orphaned code change row(s)."
|
||||
if orphan_ids else 'No orphaned code change rows were found.'
|
||||
),
|
||||
}
|
||||
|
||||
def cleanup_audit_trail(self) -> None:
|
||||
"""Clear audit-related test data across all related tables."""
|
||||
self.db.query(PromptCodeLink).delete()
|
||||
|
||||
@@ -62,6 +62,7 @@ class AgentOrchestrator:
|
||||
self.repo_name_override = repo_name_override
|
||||
self.existing_history = existing_history
|
||||
self.changed_files: list[str] = []
|
||||
self.pending_code_changes: list[dict] = []
|
||||
self.gitea_api = GiteaAPI(
|
||||
token=settings.GITEA_TOKEN,
|
||||
base_url=settings.GITEA_URL,
|
||||
@@ -457,18 +458,14 @@ class AgentOrchestrator:
|
||||
diff_text = self._build_diff_text(relative_path, previous_content, content)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
self.changed_files.append(relative_path)
|
||||
if self.db_manager and self.history:
|
||||
self.db_manager.log_code_change(
|
||||
project_id=self.project_id,
|
||||
change_type=change_type,
|
||||
file_path=relative_path,
|
||||
actor="orchestrator",
|
||||
actor_type="agent",
|
||||
details=f"{change_type.title()}d generated artifact {relative_path}",
|
||||
history_id=self.history.id,
|
||||
prompt_id=self.prompt_audit.id if self.prompt_audit else None,
|
||||
diff_summary=f"Wrote {len(content.splitlines())} lines to {relative_path}",
|
||||
diff_text=diff_text,
|
||||
self.pending_code_changes.append(
|
||||
{
|
||||
'change_type': change_type,
|
||||
'file_path': relative_path,
|
||||
'details': f"{change_type.title()}d generated artifact {relative_path}",
|
||||
'diff_summary': f"Wrote {len(content.splitlines())} lines to {relative_path}",
|
||||
'diff_text': diff_text,
|
||||
}
|
||||
)
|
||||
|
||||
def _template_files(self) -> dict[str, str]:
|
||||
@@ -668,6 +665,23 @@ class AgentOrchestrator:
|
||||
remote_status=remote_record.get("status") if remote_record else "local-only",
|
||||
related_issue=self.related_issue,
|
||||
)
|
||||
for change in self.pending_code_changes:
|
||||
self.db_manager.log_code_change(
|
||||
project_id=self.project_id,
|
||||
change_type=change['change_type'],
|
||||
file_path=change['file_path'],
|
||||
actor='orchestrator',
|
||||
actor_type='agent',
|
||||
details=change['details'],
|
||||
history_id=self.history.id if self.history else None,
|
||||
prompt_id=self.prompt_audit.id if self.prompt_audit else None,
|
||||
diff_summary=change.get('diff_summary'),
|
||||
diff_text=change.get('diff_text'),
|
||||
commit_hash=commit_hash,
|
||||
remote_status=remote_record.get('status') if remote_record else 'local-only',
|
||||
branch=self.branch_name,
|
||||
)
|
||||
self.pending_code_changes.clear()
|
||||
if self.related_issue:
|
||||
self.db_manager.log_issue_work(
|
||||
project_id=self.project_id,
|
||||
|
||||
@@ -18,6 +18,20 @@ except ImportError:
|
||||
class RequestInterpreter:
|
||||
"""Use Ollama to turn free-form text into a structured software request."""
|
||||
|
||||
REQUEST_PREFIX_WORDS = {
|
||||
'a', 'an', 'app', 'application', 'build', 'create', 'dashboard', 'develop', 'design', 'for', 'generate',
|
||||
'internal', 'make', 'me', 'modern', 'need', 'new', 'our', 'platform', 'please', 'project', 'service',
|
||||
'simple', 'site', 'start', 'system', 'the', 'tool', 'us', 'want', 'web', 'website', 'with',
|
||||
}
|
||||
|
||||
REPO_NOISE_WORDS = REQUEST_PREFIX_WORDS | {'and', 'from', 'into', 'on', 'that', 'this', 'to'}
|
||||
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('/')
|
||||
self.model = model or settings.OLLAMA_MODEL
|
||||
@@ -145,10 +159,11 @@ class RequestInterpreter:
|
||||
)
|
||||
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=interpreted.get('name') or self._derive_name(prompt_text),
|
||||
fallback_name=fallback_name,
|
||||
)
|
||||
repo_name = self._ensure_unique_repo_name(repo_name, constraints['repo_names'])
|
||||
interpreted['name'] = project_name
|
||||
@@ -158,7 +173,7 @@ class RequestInterpreter:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
fallback_name = interpreted.get('name') or self._derive_name(prompt_text)
|
||||
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'])
|
||||
return interpreted, routing, trace
|
||||
@@ -280,13 +295,22 @@ class RequestInterpreter:
|
||||
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))\b',
|
||||
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',
|
||||
@@ -301,6 +325,7 @@ class RequestInterpreter:
|
||||
"""Normalize a candidate project name into a readable title."""
|
||||
cleaned = re.sub(r'[^A-Za-z0-9\s-]+', ' ', raw_name).strip(' -')
|
||||
cleaned = re.sub(r'\s+', ' ', cleaned)
|
||||
cleaned = self._trim_request_prefix(cleaned)
|
||||
special_upper = {'api', 'crm', 'erp', 'cms', 'hr', 'it', 'ui', 'qa'}
|
||||
words = []
|
||||
for word in cleaned.split()[:6]:
|
||||
@@ -308,14 +333,79 @@ class RequestInterpreter:
|
||||
words.append(lowered.upper() if lowered in special_upper else lowered.capitalize())
|
||||
return ' '.join(words) or 'Generated Project'
|
||||
|
||||
def _trim_request_prefix(self, candidate: str) -> str:
|
||||
"""Remove leading request phrasing from model-produced names and slugs."""
|
||||
tokens = [token for token in re.split(r'[-\s]+', candidate or '') if token]
|
||||
while tokens and tokens[0].lower() in self.REQUEST_PREFIX_WORDS:
|
||||
tokens.pop(0)
|
||||
trimmed = ' '.join(tokens).strip()
|
||||
return trimmed or candidate.strip()
|
||||
|
||||
def _derive_repo_name(self, project_name: str) -> str:
|
||||
"""Derive a repository slug from a human-readable project name."""
|
||||
preferred = (project_name or 'project').strip().lower().replace(' ', '-')
|
||||
preferred_name = self._trim_request_prefix((project_name or 'project').strip())
|
||||
preferred = preferred_name.lower().replace(' ', '-')
|
||||
sanitized = ''.join(ch if ch.isalnum() or ch in {'-', '_'} else '-' for ch in preferred)
|
||||
while '--' in sanitized:
|
||||
sanitized = sanitized.replace('--', '-')
|
||||
return sanitized.strip('-') or 'project'
|
||||
|
||||
def _should_use_repo_name_candidate(self, candidate: str, project_name: str) -> bool:
|
||||
"""Return whether a model-proposed repo slug is concise enough to trust directly."""
|
||||
cleaned = self._trim_request_prefix(re.sub(r'[^A-Za-z0-9\s_-]+', ' ', candidate or '').strip())
|
||||
if not cleaned:
|
||||
return False
|
||||
candidate_tokens = [token.lower() for token in re.split(r'[-\s_]+', cleaned) if token]
|
||||
if not candidate_tokens:
|
||||
return False
|
||||
if len(candidate_tokens) > 6:
|
||||
return False
|
||||
noise_count = sum(1 for token in candidate_tokens if token in self.REPO_NOISE_WORDS)
|
||||
if noise_count >= 2:
|
||||
return False
|
||||
if len('-'.join(candidate_tokens)) > 40:
|
||||
return False
|
||||
project_tokens = {
|
||||
token.lower()
|
||||
for token in re.split(r'[-\s_]+', project_name or '')
|
||||
if token and token.lower() not in self.REPO_NOISE_WORDS
|
||||
}
|
||||
if project_tokens:
|
||||
overlap = sum(1 for token in candidate_tokens if token in project_tokens)
|
||||
if overlap == 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _should_use_project_name_candidate(self, candidate: str, fallback_name: str) -> bool:
|
||||
"""Return whether a model-proposed project title is concrete enough to trust."""
|
||||
cleaned = self._trim_request_prefix(re.sub(r'[^A-Za-z0-9\s-]+', ' ', candidate or '').strip())
|
||||
if not cleaned:
|
||||
return False
|
||||
candidate_tokens = [token.lower() for token in re.split(r'[-\s]+', cleaned) if token]
|
||||
if not candidate_tokens:
|
||||
return False
|
||||
if len(candidate_tokens) == 1 and candidate_tokens[0] in self.GENERIC_PROJECT_NAME_WORDS:
|
||||
return False
|
||||
if all(token in self.GENERIC_PROJECT_NAME_WORDS for token in candidate_tokens):
|
||||
return False
|
||||
fallback_tokens = {
|
||||
token.lower() for token in re.split(r'[-\s]+', fallback_name or '') if token and token.lower() not in self.REPO_NOISE_WORDS
|
||||
}
|
||||
if fallback_tokens and len(candidate_tokens) <= 2:
|
||||
overlap = sum(1 for token in candidate_tokens if token in fallback_tokens)
|
||||
if overlap == 0 and any(token in self.GENERIC_PROJECT_NAME_WORDS for token in candidate_tokens):
|
||||
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)
|
||||
@@ -328,8 +418,15 @@ class RequestInterpreter:
|
||||
|
||||
def _normalize_project_identity(self, payload: dict, fallback_name: str) -> tuple[str, str]:
|
||||
"""Normalize model-proposed project and repository naming."""
|
||||
project_name = self._humanize_name(str(payload.get('project_name') or payload.get('name') or fallback_name))
|
||||
repo_name = self._derive_repo_name(str(payload.get('repo_name') or project_name))
|
||||
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]:
|
||||
|
||||
@@ -60,6 +60,63 @@ EDITABLE_LLM_PROMPTS: dict[str, dict[str, str]] = {
|
||||
},
|
||||
}
|
||||
|
||||
EDITABLE_RUNTIME_SETTINGS: dict[str, dict[str, str]] = {
|
||||
'HOME_ASSISTANT_BATTERY_ENTITY_ID': {
|
||||
'label': 'Battery Entity ID',
|
||||
'category': 'home_assistant',
|
||||
'description': 'Home Assistant entity used for battery state-of-charge gating.',
|
||||
'value_type': 'string',
|
||||
},
|
||||
'HOME_ASSISTANT_SURPLUS_ENTITY_ID': {
|
||||
'label': 'Surplus Power Entity ID',
|
||||
'category': 'home_assistant',
|
||||
'description': 'Home Assistant entity used for export or surplus power gating.',
|
||||
'value_type': 'string',
|
||||
},
|
||||
'HOME_ASSISTANT_BATTERY_FULL_THRESHOLD': {
|
||||
'label': 'Battery Full Threshold',
|
||||
'category': 'home_assistant',
|
||||
'description': 'Minimum battery percentage required before queued prompts may run.',
|
||||
'value_type': 'float',
|
||||
},
|
||||
'HOME_ASSISTANT_SURPLUS_THRESHOLD_WATTS': {
|
||||
'label': 'Surplus Threshold Watts',
|
||||
'category': 'home_assistant',
|
||||
'description': 'Minimum surplus/export power required before queued prompts may run.',
|
||||
'value_type': 'float',
|
||||
},
|
||||
'PROMPT_QUEUE_ENABLED': {
|
||||
'label': 'Queue Telegram Prompts',
|
||||
'category': 'prompt_queue',
|
||||
'description': 'When enabled, Telegram prompts are queued and gated instead of processed immediately.',
|
||||
'value_type': 'boolean',
|
||||
},
|
||||
'PROMPT_QUEUE_AUTO_PROCESS': {
|
||||
'label': 'Auto Process Queue',
|
||||
'category': 'prompt_queue',
|
||||
'description': 'Let the background worker drain the queue automatically when the gate is open.',
|
||||
'value_type': 'boolean',
|
||||
},
|
||||
'PROMPT_QUEUE_FORCE_PROCESS': {
|
||||
'label': 'Force Queue Processing',
|
||||
'category': 'prompt_queue',
|
||||
'description': 'Bypass the Home Assistant energy gate for queued prompts.',
|
||||
'value_type': 'boolean',
|
||||
},
|
||||
'PROMPT_QUEUE_POLL_INTERVAL_SECONDS': {
|
||||
'label': 'Queue Poll Interval Seconds',
|
||||
'category': 'prompt_queue',
|
||||
'description': 'Polling interval for the background queue worker.',
|
||||
'value_type': 'integer',
|
||||
},
|
||||
'PROMPT_QUEUE_MAX_BATCH_SIZE': {
|
||||
'label': 'Queue Max Batch Size',
|
||||
'category': 'prompt_queue',
|
||||
'description': 'Maximum number of queued prompts processed in one batch.',
|
||||
'value_type': 'integer',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _get_persisted_llm_prompt_override(env_key: str) -> str | None:
|
||||
"""Load one persisted LLM prompt override from the database when available."""
|
||||
@@ -92,6 +149,62 @@ def _resolve_llm_prompt_value(env_key: str, fallback: str) -> str:
|
||||
return (fallback or '').strip()
|
||||
|
||||
|
||||
def _get_persisted_runtime_setting_override(key: str):
|
||||
"""Load one persisted runtime-setting override from the database when available."""
|
||||
if key not in EDITABLE_RUNTIME_SETTINGS:
|
||||
return None
|
||||
try:
|
||||
try:
|
||||
from .database import get_db_sync
|
||||
from .agents.database_manager import DatabaseManager
|
||||
except ImportError:
|
||||
from database import get_db_sync
|
||||
from agents.database_manager import DatabaseManager
|
||||
|
||||
db = get_db_sync()
|
||||
if db is None:
|
||||
return None
|
||||
try:
|
||||
return DatabaseManager(db).get_runtime_setting_override(key)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_runtime_setting_value(key: str, value, fallback):
|
||||
"""Coerce a persisted runtime setting override into the expected scalar type."""
|
||||
value_type = EDITABLE_RUNTIME_SETTINGS.get(key, {}).get('value_type')
|
||||
if value is None:
|
||||
return fallback
|
||||
if value_type == 'boolean':
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
normalized = str(value).strip().lower()
|
||||
if normalized in {'1', 'true', 'yes', 'on'}:
|
||||
return True
|
||||
if normalized in {'0', 'false', 'no', 'off'}:
|
||||
return False
|
||||
return bool(fallback)
|
||||
if value_type == 'integer':
|
||||
try:
|
||||
return int(value)
|
||||
except Exception:
|
||||
return int(fallback)
|
||||
if value_type == 'float':
|
||||
try:
|
||||
return float(value)
|
||||
except Exception:
|
||||
return float(fallback)
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _resolve_runtime_setting_value(key: str, fallback):
|
||||
"""Resolve one editable runtime setting from DB override first, then environment/defaults."""
|
||||
override = _get_persisted_runtime_setting_override(key)
|
||||
return _coerce_runtime_setting_value(key, override, fallback)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
@@ -120,10 +233,10 @@ class Settings(BaseSettings):
|
||||
"For summaries: only describe facts present in the provided context and tool outputs. Never claim a repository, commit, or pull request exists unless it is present in the supplied data."
|
||||
)
|
||||
LLM_PROJECT_NAMING_GUARDRAIL_PROMPT: str = (
|
||||
"For project naming: prefer clear, product-like names and repository slugs that match the user's intent. Avoid reusing tracked project identities unless the request is clearly asking for an existing project."
|
||||
"For project naming: prefer clear, product-like names and repository slugs that match the user's concrete deliverable. Avoid abstract or instructional words such as purpose, project, system, app, tool, platform, solution, new, create, or test unless the request truly centers on that exact noun. Base the name on the actual artifact or workflow being built, and avoid copying sentence fragments from the prompt. Avoid reusing tracked project identities unless the request is clearly asking for an existing project."
|
||||
)
|
||||
LLM_PROJECT_NAMING_SYSTEM_PROMPT: str = (
|
||||
"You name newly requested software projects. Return only JSON with keys project_name, repo_name, and rationale. Project names should be concise human-readable titles. Repo names should be lowercase kebab-case slugs suitable for a Gitea repository name."
|
||||
"You name newly requested software projects. Return only JSON with keys project_name, repo_name, and rationale. Project names should be concise human-readable titles based on the real product, artifact, or workflow being created. Repo names should be lowercase kebab-case slugs derived from that title. Never return generic names like purpose, project, system, app, tool, platform, solution, harness, or test by themselves, and never return a repo_name that is a copied sentence fragment from the prompt. Prefer 2 to 4 specific words when possible."
|
||||
)
|
||||
LLM_PROJECT_ID_GUARDRAIL_PROMPT: str = (
|
||||
"For project ids: produce short stable slugs for newly created projects. Avoid collisions with known project ids and keep ids lowercase with hyphens."
|
||||
@@ -309,6 +422,26 @@ class Settings(BaseSettings):
|
||||
)
|
||||
return prompts
|
||||
|
||||
@property
|
||||
def editable_runtime_settings(self) -> list[dict]:
|
||||
"""Return metadata for all DB-editable runtime settings."""
|
||||
items = []
|
||||
for key, metadata in EDITABLE_RUNTIME_SETTINGS.items():
|
||||
default_value = getattr(self, key)
|
||||
value = _resolve_runtime_setting_value(key, default_value)
|
||||
items.append(
|
||||
{
|
||||
'key': key,
|
||||
'label': metadata['label'],
|
||||
'category': metadata['category'],
|
||||
'description': metadata['description'],
|
||||
'value_type': metadata['value_type'],
|
||||
'default_value': default_value,
|
||||
'value': value,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
@property
|
||||
def llm_tool_allowlist(self) -> list[str]:
|
||||
"""Get the allowed LLM tool names as a normalized list."""
|
||||
@@ -438,47 +571,47 @@ class Settings(BaseSettings):
|
||||
@property
|
||||
def home_assistant_battery_entity_id(self) -> str:
|
||||
"""Get the Home Assistant battery state entity id."""
|
||||
return self.HOME_ASSISTANT_BATTERY_ENTITY_ID.strip()
|
||||
return str(_resolve_runtime_setting_value('HOME_ASSISTANT_BATTERY_ENTITY_ID', self.HOME_ASSISTANT_BATTERY_ENTITY_ID)).strip()
|
||||
|
||||
@property
|
||||
def home_assistant_surplus_entity_id(self) -> str:
|
||||
"""Get the Home Assistant surplus power entity id."""
|
||||
return self.HOME_ASSISTANT_SURPLUS_ENTITY_ID.strip()
|
||||
return str(_resolve_runtime_setting_value('HOME_ASSISTANT_SURPLUS_ENTITY_ID', self.HOME_ASSISTANT_SURPLUS_ENTITY_ID)).strip()
|
||||
|
||||
@property
|
||||
def home_assistant_battery_full_threshold(self) -> float:
|
||||
"""Get the minimum battery SoC percentage for queue processing."""
|
||||
return float(self.HOME_ASSISTANT_BATTERY_FULL_THRESHOLD)
|
||||
return float(_resolve_runtime_setting_value('HOME_ASSISTANT_BATTERY_FULL_THRESHOLD', self.HOME_ASSISTANT_BATTERY_FULL_THRESHOLD))
|
||||
|
||||
@property
|
||||
def home_assistant_surplus_threshold_watts(self) -> float:
|
||||
"""Get the minimum export/surplus power threshold for queue processing."""
|
||||
return float(self.HOME_ASSISTANT_SURPLUS_THRESHOLD_WATTS)
|
||||
return float(_resolve_runtime_setting_value('HOME_ASSISTANT_SURPLUS_THRESHOLD_WATTS', self.HOME_ASSISTANT_SURPLUS_THRESHOLD_WATTS))
|
||||
|
||||
@property
|
||||
def prompt_queue_enabled(self) -> bool:
|
||||
"""Whether Telegram prompts should be queued instead of processed immediately."""
|
||||
return bool(self.PROMPT_QUEUE_ENABLED)
|
||||
return bool(_resolve_runtime_setting_value('PROMPT_QUEUE_ENABLED', self.PROMPT_QUEUE_ENABLED))
|
||||
|
||||
@property
|
||||
def prompt_queue_auto_process(self) -> bool:
|
||||
"""Whether the background worker should automatically process queued prompts."""
|
||||
return bool(self.PROMPT_QUEUE_AUTO_PROCESS)
|
||||
return bool(_resolve_runtime_setting_value('PROMPT_QUEUE_AUTO_PROCESS', self.PROMPT_QUEUE_AUTO_PROCESS))
|
||||
|
||||
@property
|
||||
def prompt_queue_force_process(self) -> bool:
|
||||
"""Whether queued prompts should bypass the Home Assistant energy gate."""
|
||||
return bool(self.PROMPT_QUEUE_FORCE_PROCESS)
|
||||
return bool(_resolve_runtime_setting_value('PROMPT_QUEUE_FORCE_PROCESS', self.PROMPT_QUEUE_FORCE_PROCESS))
|
||||
|
||||
@property
|
||||
def prompt_queue_poll_interval_seconds(self) -> int:
|
||||
"""Get the queue polling interval for background processing."""
|
||||
return max(int(self.PROMPT_QUEUE_POLL_INTERVAL_SECONDS), 5)
|
||||
return max(int(_resolve_runtime_setting_value('PROMPT_QUEUE_POLL_INTERVAL_SECONDS', self.PROMPT_QUEUE_POLL_INTERVAL_SECONDS)), 5)
|
||||
|
||||
@property
|
||||
def prompt_queue_max_batch_size(self) -> int:
|
||||
"""Get the maximum number of queued prompts to process in one batch."""
|
||||
return max(int(self.PROMPT_QUEUE_MAX_BATCH_SIZE), 1)
|
||||
return max(int(_resolve_runtime_setting_value('PROMPT_QUEUE_MAX_BATCH_SIZE', self.PROMPT_QUEUE_MAX_BATCH_SIZE)), 1)
|
||||
|
||||
@property
|
||||
def projects_root(self) -> Path:
|
||||
|
||||
@@ -665,6 +665,24 @@ def _add_dashboard_styles() -> None:
|
||||
"""Register shared dashboard styles."""
|
||||
ui.add_head_html(
|
||||
"""
|
||||
<script>
|
||||
(() => {
|
||||
const scrollKey = 'factory-dashboard-scroll-y';
|
||||
const rememberScroll = () => sessionStorage.setItem(scrollKey, String(window.scrollY || 0));
|
||||
const restoreScroll = () => {
|
||||
const stored = sessionStorage.getItem(scrollKey);
|
||||
if (stored === null) return;
|
||||
window.requestAnimationFrame(() => window.scrollTo({top: Number(stored) || 0, left: 0, behavior: 'auto'}));
|
||||
};
|
||||
window.addEventListener('scroll', rememberScroll, {passive: true});
|
||||
document.addEventListener('click', rememberScroll, true);
|
||||
const observer = new MutationObserver(() => restoreScroll());
|
||||
window.addEventListener('load', () => {
|
||||
observer.observe(document.body, {childList: true, subtree: true});
|
||||
restoreScroll();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
body { background: radial-gradient(circle at top, #f4efe7 0%, #e9e1d4 38%, #d7cec1 100%); }
|
||||
.factory-shell { max-width: 1240px; margin: 0 auto; }
|
||||
@@ -867,10 +885,31 @@ def create_dashboard():
|
||||
repo_discovery_key = 'dashboard.repo_discovery'
|
||||
repo_owner_key = 'dashboard.repo_owner'
|
||||
repo_name_key = 'dashboard.repo_name'
|
||||
expansion_state_prefix = 'dashboard.expansion.'
|
||||
|
||||
def _expansion_state_key(name: str) -> str:
|
||||
return f'{expansion_state_prefix}{name}'
|
||||
|
||||
def _expansion_value(name: str, default: bool = False) -> bool:
|
||||
return bool(app.storage.user.get(_expansion_state_key(name), default))
|
||||
|
||||
def _store_expansion_value(name: str, event) -> None:
|
||||
app.storage.user[_expansion_state_key(name)] = bool(event.value)
|
||||
|
||||
def _sticky_expansion(name: str, text: str, *, icon: str | None = None, default: bool = False, classes: str = 'w-full'):
|
||||
return ui.expansion(
|
||||
text,
|
||||
icon=icon,
|
||||
value=_expansion_value(name, default),
|
||||
on_value_change=lambda event, expansion_name=name: _store_expansion_value(expansion_name, event),
|
||||
).classes(classes)
|
||||
|
||||
def _llm_prompt_draft_key(prompt_key: str) -> str:
|
||||
return f'dashboard.llm_prompt_draft.{prompt_key}'
|
||||
|
||||
def _runtime_setting_draft_key(setting_key: str) -> str:
|
||||
return f'dashboard.runtime_setting_draft.{setting_key}'
|
||||
|
||||
def _selected_tab_name() -> str:
|
||||
"""Return the persisted active dashboard tab."""
|
||||
return app.storage.user.get(active_tab_key, 'overview')
|
||||
@@ -940,6 +979,15 @@ def create_dashboard():
|
||||
def _clear_prompt_draft(prompt_key: str) -> None:
|
||||
app.storage.user.pop(_llm_prompt_draft_key(prompt_key), None)
|
||||
|
||||
def _runtime_setting_draft_value(setting_key: str, fallback):
|
||||
return app.storage.user.get(_runtime_setting_draft_key(setting_key), fallback)
|
||||
|
||||
def _store_runtime_setting_draft(setting_key: str, value) -> None:
|
||||
app.storage.user[_runtime_setting_draft_key(setting_key)] = value
|
||||
|
||||
def _clear_runtime_setting_draft(setting_key: str) -> None:
|
||||
app.storage.user.pop(_runtime_setting_draft_key(setting_key), None)
|
||||
|
||||
def _call_backend_json(path: str, method: str = 'GET', payload: dict | None = None) -> dict:
|
||||
target = f"{settings.backend_public_url}{path}"
|
||||
data = json.dumps(payload).encode('utf-8') if payload is not None else None
|
||||
@@ -1136,6 +1184,16 @@ def create_dashboard():
|
||||
ui.notify('Queued prompt returned to pending state', color='positive')
|
||||
_refresh_all_dashboard_sections()
|
||||
|
||||
def purge_orphan_code_changes_action(project_id: str | None = None) -> None:
|
||||
db = get_db_sync()
|
||||
if db is None:
|
||||
ui.notify('Database session could not be created', color='negative')
|
||||
return
|
||||
with closing(db):
|
||||
result = DatabaseManager(db).cleanup_orphan_code_changes(project_id=project_id)
|
||||
ui.notify(result.get('message', 'Audit cleanup completed'), color='positive')
|
||||
_refresh_all_dashboard_sections()
|
||||
|
||||
def save_llm_prompt_action(prompt_key: str) -> None:
|
||||
db = get_db_sync()
|
||||
if db is None:
|
||||
@@ -1166,6 +1224,36 @@ def create_dashboard():
|
||||
ui.notify('LLM prompt setting reset to environment default', color='positive')
|
||||
_refresh_system_sections()
|
||||
|
||||
def save_runtime_setting_action(setting_key: str) -> None:
|
||||
db = get_db_sync()
|
||||
if db is None:
|
||||
ui.notify('Database session could not be created', color='negative')
|
||||
return
|
||||
with closing(db):
|
||||
current = next((item for item in DatabaseManager(db).get_runtime_settings() if item['key'] == setting_key), None)
|
||||
value = _runtime_setting_draft_value(setting_key, current['value'] if current else None)
|
||||
result = DatabaseManager(db).save_runtime_setting(setting_key, value, actor='dashboard')
|
||||
if result.get('status') == 'error':
|
||||
ui.notify(result.get('message', 'Runtime setting save failed'), color='negative')
|
||||
return
|
||||
_clear_runtime_setting_draft(setting_key)
|
||||
ui.notify('Runtime setting saved', color='positive')
|
||||
_refresh_all_dashboard_sections()
|
||||
|
||||
def reset_runtime_setting_action(setting_key: str) -> None:
|
||||
db = get_db_sync()
|
||||
if db is None:
|
||||
ui.notify('Database session could not be created', color='negative')
|
||||
return
|
||||
with closing(db):
|
||||
result = DatabaseManager(db).reset_runtime_setting(setting_key, actor='dashboard')
|
||||
if result.get('status') == 'error':
|
||||
ui.notify(result.get('message', 'Runtime setting reset failed'), color='negative')
|
||||
return
|
||||
_clear_runtime_setting_draft(setting_key)
|
||||
ui.notify('Runtime setting reset to environment default', color='positive')
|
||||
_refresh_all_dashboard_sections()
|
||||
|
||||
def init_db_action() -> None:
|
||||
result = init_db()
|
||||
ui.notify(result.get('message', 'Database initialized'), color='positive' if result.get('status') == 'success' else 'negative')
|
||||
@@ -1244,13 +1332,16 @@ def create_dashboard():
|
||||
commit_lookup_query = _selected_commit_lookup()
|
||||
discovered_repositories = _get_discovered_repositories()
|
||||
prompt_settings = settings.editable_llm_prompts
|
||||
runtime_settings = settings.editable_runtime_settings
|
||||
db = get_db_sync()
|
||||
if db is not None:
|
||||
with closing(db):
|
||||
try:
|
||||
prompt_settings = DatabaseManager(db).get_llm_prompt_settings()
|
||||
runtime_settings = DatabaseManager(db).get_runtime_settings()
|
||||
except Exception:
|
||||
prompt_settings = settings.editable_llm_prompts
|
||||
runtime_settings = settings.editable_runtime_settings
|
||||
if snapshot.get('error'):
|
||||
return {
|
||||
'error': snapshot['error'],
|
||||
@@ -1262,6 +1353,7 @@ def create_dashboard():
|
||||
'commit_lookup_query': commit_lookup_query,
|
||||
'discovered_repositories': discovered_repositories,
|
||||
'prompt_settings': prompt_settings,
|
||||
'runtime_settings': runtime_settings,
|
||||
}
|
||||
projects = snapshot['projects']
|
||||
all_llm_traces = [trace for project_bundle in projects for trace in project_bundle.get('llm_traces', [])]
|
||||
@@ -1281,6 +1373,7 @@ def create_dashboard():
|
||||
'commit_context': _load_commit_context(commit_lookup_query, branch_scope_filter) if commit_lookup_query else None,
|
||||
'discovered_repositories': discovered_repositories,
|
||||
'prompt_settings': prompt_settings,
|
||||
'runtime_settings': runtime_settings,
|
||||
'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': {
|
||||
@@ -1337,6 +1430,7 @@ def create_dashboard():
|
||||
('Completed', summary['completed_projects'], 'Finished project runs'),
|
||||
('Prompts', summary['prompt_events'], 'Recorded originating prompts'),
|
||||
('Open PRs', summary['open_pull_requests'], 'Unmerged review branches'),
|
||||
('Orphans', summary.get('orphan_code_changes', 0), 'Generated diffs with no matching commit'),
|
||||
]
|
||||
for title, value, subtitle in metrics:
|
||||
with ui.card().classes('factory-kpi'):
|
||||
@@ -1355,15 +1449,34 @@ def create_dashboard():
|
||||
with ui.grid(columns=2).classes('w-full gap-4'):
|
||||
with ui.card().classes('factory-panel q-pa-lg'):
|
||||
ui.label('Project Pipeline').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
|
||||
if summary.get('orphan_code_changes'):
|
||||
with ui.card().classes('q-pa-md q-mt-md').style('background: #fff4dd; border: 1px solid #e0b36a;'):
|
||||
ui.label('Uncommitted generated changes detected').style('font-weight: 700; color: #7a4b16;')
|
||||
ui.label(
|
||||
f"{summary['orphan_code_changes']} generated file change row(s) have no matching git commit or PR delivery record."
|
||||
).classes('factory-muted')
|
||||
ui.button(
|
||||
'Purge orphan change rows',
|
||||
on_click=lambda: _render_confirmation_dialog(
|
||||
'Purge orphaned generated change rows?',
|
||||
'Delete only generated CODE_CHANGE audit rows that have no matching git commit. Valid prompt, commit, and PR history will be kept.',
|
||||
'Purge Orphans',
|
||||
lambda: purge_orphan_code_changes_action(),
|
||||
color='warning',
|
||||
),
|
||||
).props('outline color=warning').classes('q-mt-sm')
|
||||
if projects:
|
||||
for project_bundle in projects[:4]:
|
||||
project = project_bundle['project']
|
||||
with ui.column().classes('gap-1 q-mt-md'):
|
||||
with ui.row().classes('justify-between items-center'):
|
||||
ui.label(project['project_name']).style('font-weight: 700; color: #2f241d;')
|
||||
with ui.row().classes('items-center gap-2'):
|
||||
if project.get('delivery_status') == 'uncommitted':
|
||||
ui.label('uncommitted delivery').classes('factory-chip')
|
||||
ui.label(project['status']).classes('factory-chip')
|
||||
ui.linear_progress(value=(project['progress'] or 0) / 100, show_value=False).classes('w-full')
|
||||
ui.label(project['message'] or 'No status message').classes('factory-muted')
|
||||
ui.label(project.get('delivery_message') if project.get('delivery_status') == 'uncommitted' else project['message'] or 'No status message').classes('factory-muted')
|
||||
else:
|
||||
ui.label('No projects in the database yet.').classes('factory-muted')
|
||||
|
||||
@@ -1393,7 +1506,12 @@ def create_dashboard():
|
||||
ui.label('No project data available yet.').classes('factory-muted')
|
||||
for project_bundle in projects:
|
||||
project = project_bundle['project']
|
||||
with ui.expansion(f"{project['project_name']} · {project['status']}", icon='folder').classes('factory-panel w-full q-mb-md'):
|
||||
with _sticky_expansion(
|
||||
f"projects.{project['project_id']}",
|
||||
f"{project['project_name']} · {project['status']}",
|
||||
icon='folder',
|
||||
classes='factory-panel w-full q-mb-md',
|
||||
):
|
||||
with ui.row().classes('items-center gap-2 q-pa-md'):
|
||||
ui.button(
|
||||
'Archive',
|
||||
@@ -1414,6 +1532,22 @@ def create_dashboard():
|
||||
lambda: delete_project_action(project_id),
|
||||
),
|
||||
).props('outline color=negative')
|
||||
if project.get('delivery_status') == 'uncommitted':
|
||||
with ui.card().classes('q-ma-md q-pa-md').style('background: #fff4dd; border: 1px solid #e0b36a;'):
|
||||
with ui.row().classes('items-center justify-between w-full gap-3'):
|
||||
with ui.column().classes('gap-1'):
|
||||
ui.label('Uncommitted delivery detected').style('font-weight: 700; color: #7a4b16;')
|
||||
ui.label(project.get('delivery_message') or 'Generated changes were recorded without a matching commit.').classes('factory-muted')
|
||||
ui.button(
|
||||
'Purge project orphan rows',
|
||||
on_click=lambda _=None, project_id=project['project_id']: _render_confirmation_dialog(
|
||||
'Purge orphaned generated change rows for this project?',
|
||||
'Delete only generated CODE_CHANGE audit rows for this project that have no matching git commit. Valid history remains intact.',
|
||||
'Purge Project Orphans',
|
||||
lambda: purge_orphan_code_changes_action(project_id),
|
||||
color='warning',
|
||||
),
|
||||
).props('outline color=warning')
|
||||
with ui.grid(columns=2).classes('w-full gap-4 q-pa-md'):
|
||||
with ui.card().classes('q-pa-md'):
|
||||
ui.label('Repository').style('font-weight: 700; color: #3a281a;')
|
||||
@@ -1438,7 +1572,12 @@ def create_dashboard():
|
||||
ui.label('No archived projects yet.').classes('factory-muted')
|
||||
for project_bundle in archived_projects:
|
||||
project = project_bundle['project']
|
||||
with ui.expansion(f"{project['project_name']} · archived", icon='archive').classes('factory-panel w-full q-mb-md'):
|
||||
with _sticky_expansion(
|
||||
f"archived.{project['project_id']}",
|
||||
f"{project['project_name']} · archived",
|
||||
icon='archive',
|
||||
classes='factory-panel w-full q-mb-md',
|
||||
):
|
||||
with ui.row().classes('items-center gap-2 q-pa-md'):
|
||||
ui.button(
|
||||
'Restore',
|
||||
@@ -1459,6 +1598,20 @@ def create_dashboard():
|
||||
lambda: delete_project_action(project_id),
|
||||
),
|
||||
).props('outline color=negative')
|
||||
if project.get('delivery_status') == 'uncommitted':
|
||||
with ui.card().classes('q-ma-md q-pa-md').style('background: #fff4dd; border: 1px solid #e0b36a;'):
|
||||
ui.label('Archived project contains uncommitted generated change rows').style('font-weight: 700; color: #7a4b16;')
|
||||
ui.label(project.get('delivery_message') or 'Generated changes were recorded without a matching commit.').classes('factory-muted')
|
||||
ui.button(
|
||||
'Purge archived project orphan rows',
|
||||
on_click=lambda _=None, project_id=project['project_id']: _render_confirmation_dialog(
|
||||
'Purge orphaned generated change rows for this archived project?',
|
||||
'Delete only generated CODE_CHANGE audit rows for this project that have no matching git commit. Valid history remains intact.',
|
||||
'Purge Archived Orphans',
|
||||
lambda: purge_orphan_code_changes_action(project_id),
|
||||
color='warning',
|
||||
),
|
||||
).props('outline color=warning').classes('q-mt-sm')
|
||||
with ui.grid(columns=2).classes('w-full gap-4 q-pa-md'):
|
||||
with ui.card().classes('q-pa-md'):
|
||||
ui.label('Repository').style('font-weight: 700; color: #3a281a;')
|
||||
@@ -1645,7 +1798,12 @@ def create_dashboard():
|
||||
if projects:
|
||||
for project_bundle in projects:
|
||||
project = project_bundle['project']
|
||||
with ui.expansion(f"{project['project_name']} · {project['project_id']}", icon='schedule').classes('q-mt-md w-full'):
|
||||
with _sticky_expansion(
|
||||
f"timeline.{project['project_id']}",
|
||||
f"{project['project_name']} · {project['project_id']}",
|
||||
icon='schedule',
|
||||
classes='q-mt-md w-full',
|
||||
):
|
||||
_render_timeline(_filter_timeline_events(project_bundle.get('timeline', []), branch_scope_filter))
|
||||
else:
|
||||
ui.label('No project timelines recorded yet.').classes('factory-muted')
|
||||
@@ -1660,6 +1818,7 @@ def create_dashboard():
|
||||
llm_runtime = view_model['llm_runtime']
|
||||
discovered_repositories = view_model['discovered_repositories']
|
||||
prompt_settings = view_model.get('prompt_settings', [])
|
||||
runtime_settings = view_model.get('runtime_settings', [])
|
||||
with ui.grid(columns=2).classes('w-full gap-4'):
|
||||
with ui.card().classes('factory-panel q-pa-lg'):
|
||||
ui.label('System Logs').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
|
||||
@@ -1710,6 +1869,45 @@ 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('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')
|
||||
for setting in runtime_settings:
|
||||
with ui.card().classes('q-pa-sm q-mt-md'):
|
||||
with ui.row().classes('items-center justify-between w-full'):
|
||||
with ui.column().classes('gap-1'):
|
||||
ui.label(setting['label']).style('font-weight: 700; color: #2f241d;')
|
||||
ui.label(setting.get('description') or '').classes('factory-muted')
|
||||
with ui.row().classes('items-center gap-2'):
|
||||
ui.label(setting.get('category', 'setting')).classes('factory-chip')
|
||||
ui.label(setting.get('source', 'environment')).classes('factory-chip')
|
||||
draft_value = _runtime_setting_draft_value(setting['key'], setting.get('value'))
|
||||
if setting.get('value_type') == 'boolean':
|
||||
ui.switch(
|
||||
value=bool(draft_value),
|
||||
on_change=lambda event, setting_key=setting['key']: _store_runtime_setting_draft(setting_key, bool(event.value)),
|
||||
).props('color=accent').classes('q-mt-sm')
|
||||
elif setting.get('value_type') == 'integer':
|
||||
ui.number(
|
||||
value=int(draft_value),
|
||||
on_change=lambda event, setting_key=setting['key']: _store_runtime_setting_draft(setting_key, int(event.value) if event.value is not None else None),
|
||||
).classes('w-full q-mt-sm')
|
||||
elif setting.get('value_type') == 'float':
|
||||
ui.number(
|
||||
value=float(draft_value),
|
||||
on_change=lambda event, setting_key=setting['key']: _store_runtime_setting_draft(setting_key, float(event.value) if event.value is not None else None),
|
||||
).classes('w-full q-mt-sm')
|
||||
else:
|
||||
ui.input(
|
||||
value=str(draft_value or ''),
|
||||
on_change=lambda event, setting_key=setting['key']: _store_runtime_setting_draft(setting_key, event.value or ''),
|
||||
).classes('w-full q-mt-sm')
|
||||
ui.label(f"Environment default: {setting.get('default_value')}").classes('factory-muted q-mt-sm')
|
||||
if setting.get('updated_at'):
|
||||
ui.label(f"Last updated: {setting['updated_at']} by {setting.get('updated_by') or 'unknown'}").classes('factory-muted q-mt-sm')
|
||||
with ui.row().classes('items-center gap-2 q-mt-md'):
|
||||
ui.button('Save Override', on_click=lambda _=None, setting_key=setting['key']: save_runtime_setting_action(setting_key)).props('unelevated color=accent')
|
||||
ui.button('Reset To Default', on_click=lambda _=None, setting_key=setting['key']: reset_runtime_setting_action(setting_key)).props('outline color=warning')
|
||||
with ui.card().classes('factory-panel q-pa-lg'):
|
||||
ui.label('Editable LLM Prompts').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
|
||||
ui.label('These guardrails and system prompts are persisted in the database and override environment defaults until reset.').classes('factory-muted')
|
||||
@@ -1831,7 +2029,8 @@ def create_dashboard():
|
||||
_update_dashboard_state()
|
||||
panel_refreshers['metrics']()
|
||||
active_tab = _selected_tab_name()
|
||||
if active_tab in panel_refreshers:
|
||||
# Avoid rebuilding the more interactive tabs on the timer; manual refresh keeps them current.
|
||||
if active_tab in {'overview', 'health'} and active_tab in panel_refreshers:
|
||||
panel_refreshers[active_tab]()
|
||||
|
||||
def _refresh_all_dashboard_sections() -> None:
|
||||
|
||||
@@ -62,8 +62,6 @@ async def lifespan(_app: FastAPI):
|
||||
print(
|
||||
f"Runtime configuration: database_backend={runtime['backend']} target={runtime['target']}"
|
||||
)
|
||||
queue_worker = None
|
||||
if database_module.settings.prompt_queue_enabled and database_module.settings.prompt_queue_auto_process:
|
||||
queue_worker = asyncio.create_task(_prompt_queue_worker())
|
||||
try:
|
||||
yield
|
||||
@@ -124,6 +122,12 @@ class LLMPromptSettingUpdateRequest(BaseModel):
|
||||
value: str = Field(default='')
|
||||
|
||||
|
||||
class RuntimeSettingUpdateRequest(BaseModel):
|
||||
"""Request body for persisting one editable runtime setting override."""
|
||||
|
||||
value: str | bool | int | float | None = None
|
||||
|
||||
|
||||
class GiteaRepositoryOnboardRequest(BaseModel):
|
||||
"""Request body for onboarding a manually created Gitea repository."""
|
||||
|
||||
@@ -681,6 +685,7 @@ async def _prompt_queue_worker() -> None:
|
||||
"""Background worker that drains the prompt queue when the energy gate opens."""
|
||||
while True:
|
||||
try:
|
||||
if database_module.settings.prompt_queue_enabled and database_module.settings.prompt_queue_auto_process:
|
||||
await _process_prompt_queue_batch(
|
||||
limit=database_module.settings.prompt_queue_max_batch_size,
|
||||
force=database_module.settings.prompt_queue_force_process,
|
||||
@@ -719,6 +724,8 @@ def read_api_info():
|
||||
'/llm/runtime',
|
||||
'/llm/prompts',
|
||||
'/llm/prompts/{prompt_key}',
|
||||
'/settings/runtime',
|
||||
'/settings/runtime/{setting_key}',
|
||||
'/generate',
|
||||
'/generate/text',
|
||||
'/queue',
|
||||
@@ -798,6 +805,7 @@ def get_llm_prompt_settings(db: DbSession):
|
||||
@app.put('/llm/prompts/{prompt_key}')
|
||||
def update_llm_prompt_setting(prompt_key: str, request: LLMPromptSettingUpdateRequest, db: DbSession):
|
||||
"""Persist one editable LLM prompt override into the database."""
|
||||
database_module.init_db()
|
||||
result = DatabaseManager(db).save_llm_prompt_setting(prompt_key, request.value, actor='api')
|
||||
if result.get('status') == 'error':
|
||||
raise HTTPException(status_code=400, detail=result.get('message', 'Prompt save failed'))
|
||||
@@ -807,12 +815,39 @@ def update_llm_prompt_setting(prompt_key: str, request: LLMPromptSettingUpdateRe
|
||||
@app.delete('/llm/prompts/{prompt_key}')
|
||||
def reset_llm_prompt_setting(prompt_key: str, db: DbSession):
|
||||
"""Reset one editable LLM prompt override back to the environment/default value."""
|
||||
database_module.init_db()
|
||||
result = DatabaseManager(db).reset_llm_prompt_setting(prompt_key, actor='api')
|
||||
if result.get('status') == 'error':
|
||||
raise HTTPException(status_code=400, detail=result.get('message', 'Prompt reset failed'))
|
||||
return result
|
||||
|
||||
|
||||
@app.get('/settings/runtime')
|
||||
def get_runtime_settings(db: DbSession):
|
||||
"""Return editable runtime settings with DB overrides merged over environment defaults."""
|
||||
return {'settings': DatabaseManager(db).get_runtime_settings()}
|
||||
|
||||
|
||||
@app.put('/settings/runtime/{setting_key}')
|
||||
def update_runtime_setting(setting_key: str, request: RuntimeSettingUpdateRequest, db: DbSession):
|
||||
"""Persist one editable runtime setting override into the database."""
|
||||
database_module.init_db()
|
||||
result = DatabaseManager(db).save_runtime_setting(setting_key, request.value, actor='api')
|
||||
if result.get('status') == 'error':
|
||||
raise HTTPException(status_code=400, detail=result.get('message', 'Runtime setting save failed'))
|
||||
return result
|
||||
|
||||
|
||||
@app.delete('/settings/runtime/{setting_key}')
|
||||
def reset_runtime_setting(setting_key: str, db: DbSession):
|
||||
"""Reset one editable runtime setting override back to the environment/default value."""
|
||||
database_module.init_db()
|
||||
result = DatabaseManager(db).reset_runtime_setting(setting_key, actor='api')
|
||||
if result.get('status') == 'error':
|
||||
raise HTTPException(status_code=400, detail=result.get('message', 'Runtime setting reset failed'))
|
||||
return result
|
||||
|
||||
|
||||
@app.post('/generate')
|
||||
async def generate_software(request: SoftwareRequest, db: DbSession):
|
||||
"""Create and record a software-generation request."""
|
||||
|
||||
Reference in New Issue
Block a user