feat: better history data, refs NOISSUE

This commit is contained in:
2026-04-10 23:52:08 +02:00
parent 032139c14f
commit fd812476cc
11 changed files with 2643 additions and 93 deletions

View File

@@ -27,19 +27,23 @@ from sqlalchemy.orm import Session
try:
from . import __version__, frontend
from . import database as database_module
from .agents.change_summary import ChangeSummaryGenerator
from .agents.database_manager import DatabaseManager
from .agents.request_interpreter import RequestInterpreter
from .agents.orchestrator import AgentOrchestrator
from .agents.n8n_setup import N8NSetupAgent
from .agents.prompt_workflow import PromptWorkflowManager
from .agents.ui_manager import UIManager
from .models import ProjectHistory, ProjectLog, SystemLog
except ImportError:
import frontend
import database as database_module
from agents.change_summary import ChangeSummaryGenerator
from agents.database_manager import DatabaseManager
from agents.request_interpreter import RequestInterpreter
from agents.orchestrator import AgentOrchestrator
from agents.n8n_setup import N8NSetupAgent
from agents.prompt_workflow import PromptWorkflowManager
from agents.ui_manager import UIManager
from models import ProjectHistory, ProjectLog, SystemLog
@@ -90,6 +94,15 @@ class FreeformSoftwareRequest(BaseModel):
chat_type: str | None = None
class GiteaRepositoryOnboardRequest(BaseModel):
"""Request body for onboarding a manually created Gitea repository."""
repo_name: str = Field(min_length=1, max_length=255)
owner: str | None = None
sync_commits: bool = True
commit_limit: int = Field(default=25, ge=1, le=200)
def _build_project_id(name: str) -> str:
"""Create a stable project id from the requested name."""
slug = PROJECT_ID_PATTERN.sub("-", name.strip().lower()).strip("-") or "project"
@@ -164,7 +177,27 @@ async def _run_generation(
"""Run the shared generation pipeline for a structured request."""
database_module.init_db()
project_id = _build_project_id(request.name)
manager = DatabaseManager(db)
reusable_history = manager.get_latest_project_by_name(request.name)
if reusable_history and database_module.settings.gitea_url and database_module.settings.gitea_token:
try:
from .agents.gitea import GiteaAPI
except ImportError:
from agents.gitea import GiteaAPI
manager.sync_pull_request_states(
GiteaAPI(
token=database_module.settings.GITEA_TOKEN,
base_url=database_module.settings.GITEA_URL,
owner=database_module.settings.GITEA_OWNER,
repo=database_module.settings.GITEA_REPO or '',
),
project_id=reusable_history.project_id,
)
if reusable_history and manager.get_open_pull_request(project_id=reusable_history.project_id):
project_id = reusable_history.project_id
else:
project_id = _build_project_id(request.name)
reusable_history = None
resolved_prompt_text = prompt_text or _compose_prompt_text(request)
orchestrator = AgentOrchestrator(
project_id=project_id,
@@ -175,6 +208,7 @@ async def _run_generation(
db=db,
prompt_text=resolved_prompt_text,
prompt_actor=prompt_actor,
existing_history=reusable_history,
)
result = await orchestrator.run()
@@ -195,7 +229,43 @@ async def _run_generation(
response_data['project_root'] = result.get('project_root', str(_project_root(project_id)))
response_data['changed_files'] = result.get('changed_files', [])
response_data['repository'] = result.get('repository')
return {'status': result['status'], 'data': response_data}
response_data['pull_request'] = result.get('pull_request') or manager.get_open_pull_request(project_id=project_id)
summary_context = {
'name': response_data['name'],
'description': response_data['description'],
'features': response_data['features'],
'tech_stack': response_data['tech_stack'],
'changed_files': response_data['changed_files'],
'repository_url': (
(response_data.get('repository') or {}).get('url')
if isinstance(response_data.get('repository'), dict)
and (response_data.get('repository') or {}).get('status') in {'created', 'exists', 'ready', 'shared'}
else None
),
'repository_status': (response_data.get('repository') or {}).get('status') if isinstance(response_data.get('repository'), dict) else None,
'pull_request_url': (response_data.get('pull_request') or {}).get('pr_url') if isinstance(response_data.get('pull_request'), dict) else None,
'pull_request_state': (response_data.get('pull_request') or {}).get('pr_state') if isinstance(response_data.get('pull_request'), dict) else None,
'message': response_data.get('message'),
'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)
if orchestrator.db_manager and orchestrator.history and orchestrator.prompt_audit:
orchestrator.db_manager.log_llm_trace(
project_id=project_id,
history_id=orchestrator.history.id,
prompt_id=orchestrator.prompt_audit.id,
stage=summary_trace['stage'],
provider=summary_trace['provider'],
model=summary_trace['model'],
system_prompt=summary_trace['system_prompt'],
user_prompt=summary_trace['user_prompt'],
assistant_response=summary_trace['assistant_response'],
raw_response=summary_trace.get('raw_response'),
fallback_used=summary_trace.get('fallback_used', False),
)
response_data['summary_message'] = summary_message
response_data['pull_request'] = result.get('pull_request') or manager.get_open_pull_request(project_id=project_id)
return {'status': result['status'], 'data': response_data, 'summary_message': summary_message}
def _project_root(project_id: str) -> Path:
@@ -203,6 +273,22 @@ def _project_root(project_id: str) -> Path:
return database_module.settings.projects_root / project_id
def _create_gitea_api():
"""Create a configured Gitea client or raise an HTTP error if unavailable."""
if not database_module.settings.gitea_url or not database_module.settings.gitea_token:
raise HTTPException(status_code=400, detail='Gitea integration is not configured')
try:
from .agents.gitea import GiteaAPI
except ImportError:
from agents.gitea import GiteaAPI
return GiteaAPI(
token=database_module.settings.GITEA_TOKEN,
base_url=database_module.settings.GITEA_URL,
owner=database_module.settings.GITEA_OWNER,
repo=database_module.settings.GITEA_REPO or '',
)
def _resolve_n8n_api_url(explicit_url: str | None = None) -> str:
"""Resolve the effective n8n API URL from explicit input or settings."""
if explicit_url and explicit_url.strip():
@@ -234,8 +320,16 @@ def read_api_info():
'/audit/system/logs',
'/audit/prompts',
'/audit/changes',
'/audit/commit-context',
'/audit/timeline',
'/audit/llm-traces',
'/audit/pull-requests',
'/audit/lineage',
'/audit/correlations',
'/projects/{project_id}/prompts/{prompt_id}/undo',
'/projects/{project_id}/sync-repository',
'/gitea/repos',
'/gitea/repos/onboard',
'/n8n/health',
'/n8n/setup',
],
@@ -279,7 +373,7 @@ async def generate_software_from_text(request: FreeformSoftwareRequest, db: DbSe
},
}
interpreted = await RequestInterpreter().interpret(request.prompt_text)
interpreted, interpretation_trace = await RequestInterpreter().interpret_with_trace(request.prompt_text)
structured_request = SoftwareRequest(**interpreted)
response = await _run_generation(
structured_request,
@@ -287,7 +381,26 @@ async def generate_software_from_text(request: FreeformSoftwareRequest, db: DbSe
prompt_text=request.prompt_text,
prompt_actor=request.source,
)
project_data = response.get('data', {})
if project_data.get('history_id') is not None:
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(
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),
)
response['interpreted_request'] = interpreted
response['llm_trace'] = interpretation_trace
response['source'] = {
'type': request.source,
'chat_id': request.chat_id,
@@ -343,6 +456,54 @@ def get_code_change_audit(db: DbSession, project_id: str | None = Query(default=
return {'changes': [_serialize_audit_item(item) for item in manager.get_code_changes(project_id=project_id)]}
@app.get('/audit/commit-context')
def get_commit_context_audit(
db: DbSession,
commit_hash: str = Query(min_length=4),
project_id: str | None = Query(default=None),
branch_scope: str | None = Query(default=None, pattern='^(main|pr|manual)?$'),
):
"""Return the recorded context explaining how a commit came to be."""
manager = DatabaseManager(db)
context = manager.get_commit_context(commit_hash=commit_hash, project_id=project_id, branch_scope=branch_scope)
if context is None:
raise HTTPException(status_code=404, detail='Commit context not found')
return context
@app.get('/audit/timeline')
def get_project_timeline_audit(
db: DbSession,
project_id: str = Query(min_length=1),
branch_scope: str | None = Query(default=None, pattern='^(main|pr|manual)?$'),
):
"""Return the mixed audit timeline for one project."""
manager = DatabaseManager(db)
return {'timeline': manager.get_project_timeline(project_id=project_id, branch_scope=branch_scope)}
@app.get('/audit/llm-traces')
def get_llm_trace_audit(
db: DbSession,
project_id: str | None = Query(default=None),
prompt_id: int | None = Query(default=None),
stage: str | None = Query(default=None),
model: str | None = Query(default=None),
search: str | None = Query(default=None),
):
"""Return persisted LLM traces."""
manager = DatabaseManager(db)
return {
'llm_traces': manager.get_llm_traces(
project_id=project_id,
prompt_id=prompt_id,
stage=stage,
model=model,
search_query=search,
)
}
@app.get('/audit/lineage')
def get_prompt_change_lineage(db: DbSession, project_id: str | None = Query(default=None)):
"""Return explicit prompt-to-code lineage rows."""
@@ -357,6 +518,84 @@ def get_prompt_change_correlations(db: DbSession, project_id: str | None = Query
return {'correlations': manager.get_prompt_change_correlations(project_id=project_id)}
@app.get('/audit/pull-requests')
def get_pull_request_audit(db: DbSession, project_id: str | None = Query(default=None), open_only: bool = Query(default=False)):
"""Return tracked pull requests for generated projects."""
manager = DatabaseManager(db)
return {'pull_requests': manager.get_pull_requests(project_id=project_id, only_open=open_only)}
@app.post('/projects/{project_id}/prompts/{prompt_id}/undo')
async def undo_prompt_changes(project_id: str, prompt_id: int, db: DbSession):
"""Undo all changes associated with a specific prompt."""
result = await PromptWorkflowManager(db).undo_prompt(project_id=project_id, prompt_id=prompt_id)
if result.get('status') == 'error':
raise HTTPException(status_code=400, detail=result.get('message', 'Undo failed'))
return result
@app.post('/projects/{project_id}/sync-repository')
def sync_project_repository(project_id: str, db: DbSession, commit_limit: int = Query(default=25, ge=1, le=200)):
"""Import recent repository activity from Gitea for a tracked project."""
manager = DatabaseManager(db)
result = manager.sync_repository_activity(project_id=project_id, gitea_api=_create_gitea_api(), commit_limit=commit_limit)
if result.get('status') == 'error':
raise HTTPException(status_code=400, detail=result.get('message', 'Repository sync failed'))
return result
@app.get('/gitea/repos')
def list_gitea_repositories(db: DbSession, owner: str | None = Query(default=None)):
"""List repositories in the configured Gitea organization and whether they are already onboarded."""
gitea_api = _create_gitea_api()
resolved_owner = owner or database_module.settings.gitea_owner
repos = gitea_api.list_repositories_sync(owner=resolved_owner)
if isinstance(repos, dict) and repos.get('error'):
raise HTTPException(status_code=502, detail=repos.get('error'))
manager = DatabaseManager(db)
items = []
for repo in repos if isinstance(repos, list) else []:
tracked_project = manager.get_project_by_repository(resolved_owner, repo.get('name', ''))
items.append(
{
'name': repo.get('name'),
'full_name': repo.get('full_name') or f"{resolved_owner}/{repo.get('name')}",
'description': repo.get('description'),
'html_url': repo.get('html_url'),
'clone_url': repo.get('clone_url'),
'default_branch': repo.get('default_branch'),
'private': bool(repo.get('private', False)),
'onboarded': tracked_project is not None,
'project_id': tracked_project.project_id if tracked_project is not None else None,
}
)
return {'repositories': items}
@app.post('/gitea/repos/onboard')
async def onboard_gitea_repository(request: GiteaRepositoryOnboardRequest, db: DbSession):
"""Onboard a manually created Gitea repository into the factory dashboard."""
gitea_api = _create_gitea_api()
owner = request.owner or database_module.settings.gitea_owner
repo = await gitea_api.get_repo_info(owner=owner, repo=request.repo_name)
if isinstance(repo, dict) and repo.get('error'):
raise HTTPException(status_code=404, detail=repo.get('error'))
manager = DatabaseManager(db)
onboarded = manager.onboard_repository(owner=owner, repo_name=request.repo_name, repository_data=repo)
sync_result = None
if request.sync_commits:
sync_result = manager.sync_repository_activity(
project_id=onboarded['project_id'],
gitea_api=gitea_api,
commit_limit=request.commit_limit,
)
return {
'status': 'success',
'onboarded': onboarded,
'sync_result': sync_result,
}
@app.get('/audit/logs')
def get_audit_logs(db: DbSession):
"""Return all project logs ordered newest first."""