feat: gitea issue integration, refs NOISSUE

This commit is contained in:
2026-04-11 00:10:51 +02:00
parent fd812476cc
commit 356c388efb
6 changed files with 627 additions and 32 deletions

View File

@@ -33,6 +33,7 @@ except ImportError:
)
from datetime import datetime
import json
import re
class DatabaseMigrations:
@@ -134,6 +135,41 @@ class DatabaseManager:
return 'pr'
return 'manual'
@staticmethod
def _normalize_issue(issue: object) -> dict | None:
"""Normalize issue payloads from audit metadata or Gitea responses."""
if issue is None:
return None
if not isinstance(issue, dict):
return {'number': issue}
number = issue.get('number') or issue.get('issue_number') or issue.get('id')
if number is None:
return None
labels = issue.get('labels') or []
normalized_labels = []
for label in labels:
if isinstance(label, dict):
normalized_labels.append(label.get('name') or str(label.get('id') or ''))
elif label:
normalized_labels.append(str(label))
return {
'number': number,
'title': issue.get('title'),
'state': issue.get('state'),
'url': issue.get('html_url') or issue.get('url'),
'body': issue.get('body'),
'labels': [label for label in normalized_labels if label],
'assignee': (issue.get('assignee') or {}).get('login') if isinstance(issue.get('assignee'), dict) else issue.get('assignee'),
}
@staticmethod
def extract_issue_number_from_text(text: str | None) -> int | None:
"""Extract an issue reference like #123 or issue 123 from free-form text."""
if not text:
return None
match = re.search(r'(?:#|issue\s+)(\d+)', text, flags=re.IGNORECASE)
return int(match.group(1)) if match else None
def log_project_start(self, project_id: str, project_name: str, description: str) -> ProjectHistory:
"""Log project start."""
history = ProjectHistory(
@@ -170,6 +206,9 @@ class DatabaseManager:
actor_name: str = "api",
actor_type: str = "user",
source: str = "generate-endpoint",
related_issue: dict | None = None,
source_context: dict | None = None,
routing: dict | None = None,
) -> AuditTrail | None:
"""Persist the originating prompt so later code changes can be correlated to it."""
history = self.db.query(ProjectHistory).filter(ProjectHistory.id == history_id).first()
@@ -194,6 +233,9 @@ class DatabaseManager:
"features": feature_list,
"tech_stack": tech_list,
"source": source,
"related_issue": self._normalize_issue(related_issue),
"source_context": source_context or {},
"routing": routing or {},
},
)
@@ -210,6 +252,9 @@ class DatabaseManager:
"features": feature_list,
"tech_stack": tech_list,
"source": source,
"related_issue": self._normalize_issue(related_issue),
"source_context": source_context or {},
"routing": routing or {},
},
)
self.db.add(audit)
@@ -217,6 +262,129 @@ class DatabaseManager:
self.db.refresh(audit)
return audit
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()
if prompt is None:
return None
metadata = self._normalize_metadata(prompt.metadata_json)
metadata['related_issue'] = self._normalize_issue(related_issue)
prompt.metadata_json = metadata
self.db.commit()
self.db.refresh(prompt)
return prompt
def log_issue_work(
self,
project_id: str,
history_id: int | None,
prompt_id: int | None,
issue: dict,
actor: str = 'orchestrator',
commit_hash: str | None = None,
commit_url: str | None = None,
) -> AuditTrail:
"""Record that a prompt or commit worked on a specific repository issue."""
normalized_issue = self._normalize_issue(issue) or {}
return self._log_audit_trail(
project_id=project_id,
action='ISSUE_WORKED',
actor=actor,
action_type='ISSUE',
details=f"Worked on issue #{normalized_issue.get('number')}",
message=f"Issue #{normalized_issue.get('number')} worked on",
metadata_json={
'history_id': history_id,
'prompt_id': prompt_id,
'issue': normalized_issue,
'commit_hash': commit_hash,
'commit_url': commit_url,
},
)
def sync_repository_issues(self, project_id: str, gitea_api, state: str = 'open') -> dict:
"""Import repository issues from Gitea into audit history."""
history = self.get_project_by_id(project_id)
if history is None:
return {'status': 'error', 'message': 'Project not found'}
repository = self._get_project_repository(history) or {}
owner = repository.get('owner')
repo_name = repository.get('name')
if not owner or not repo_name:
return {'status': 'error', 'message': 'Repository metadata is missing for this project'}
issues = gitea_api.list_issues_sync(owner=owner, repo=repo_name, state=state)
if isinstance(issues, dict) and issues.get('error'):
return {'status': 'error', 'message': issues.get('error')}
synced = []
for issue in issues if isinstance(issues, list) else []:
if issue.get('pull_request'):
continue
normalized_issue = self._normalize_issue(issue)
if not normalized_issue:
continue
synced.append(normalized_issue)
self._log_audit_trail(
project_id=project_id,
action='REPOSITORY_ISSUE',
actor='gitea-sync',
action_type='ISSUE',
details=f"Issue #{normalized_issue.get('number')}: {normalized_issue.get('title') or ''}",
message=f"Tracked issue #{normalized_issue.get('number')}",
metadata_json={
'history_id': history.id,
'issue': normalized_issue,
},
)
return {
'status': 'success',
'message': f'Synced {len(synced)} issue(s) for {owner}/{repo_name}',
'project_id': project_id,
'issues': synced,
}
def get_repository_issues(self, project_id: str | None = None, state: str | None = None, limit: int = 200) -> list[dict]:
"""Return the latest known issue snapshot for a repository/project."""
query = self.db.query(AuditTrail).filter(AuditTrail.action == 'REPOSITORY_ISSUE')
if project_id:
query = query.filter(AuditTrail.project_id == project_id)
rows = query.order_by(AuditTrail.created_at.desc(), AuditTrail.id.desc()).limit(limit).all()
latest_by_issue: dict[tuple[str | None, int], dict] = {}
for row in rows:
metadata = self._normalize_metadata(row.metadata_json)
issue = self._normalize_issue(metadata.get('issue'))
if not issue:
continue
if state and issue.get('state') != state:
continue
key = (row.project_id, int(issue['number']))
if key in latest_by_issue:
continue
latest_by_issue[key] = {
**issue,
'project_id': row.project_id,
'timestamp': row.created_at.isoformat() if row.created_at else None,
}
return list(latest_by_issue.values())
def get_issue_work_events(self, project_id: str | None = None, limit: int = 200) -> list[dict]:
"""Return issue-work audit events for a project."""
query = self.db.query(AuditTrail).filter(AuditTrail.action == 'ISSUE_WORKED')
if project_id:
query = query.filter(AuditTrail.project_id == project_id)
rows = query.order_by(AuditTrail.created_at.desc(), AuditTrail.id.desc()).limit(limit).all()
return [
{
'id': row.id,
'project_id': row.project_id,
'prompt_id': self._normalize_metadata(row.metadata_json).get('prompt_id'),
'issue': self._normalize_issue(self._normalize_metadata(row.metadata_json).get('issue')),
'commit_hash': self._normalize_metadata(row.metadata_json).get('commit_hash'),
'commit_url': self._normalize_metadata(row.metadata_json).get('commit_url'),
'timestamp': row.created_at.isoformat() if row.created_at else None,
}
for row in rows
]
def log_llm_trace(
self,
project_id: str,
@@ -649,6 +817,64 @@ class DatabaseManager:
"""Get project by ID."""
return self.db.query(ProjectHistory).filter(ProjectHistory.project_id == project_id).first()
def get_recent_chat_history(self, chat_id: str, source: str = 'telegram', limit: int = 12) -> list[dict]:
"""Return recent prompt events for one chat/source conversation."""
if not chat_id:
return []
prompts = self.db.query(AuditTrail).filter(AuditTrail.action == 'PROMPT_RECEIVED').order_by(
AuditTrail.created_at.desc(), AuditTrail.id.desc()
).limit(limit * 5).all()
result = []
for prompt in prompts:
metadata = self._normalize_metadata(prompt.metadata_json)
source_context = metadata.get('source_context') or {}
if metadata.get('source') != source:
continue
if str(source_context.get('chat_id') or '') != str(chat_id):
continue
result.append(
{
'prompt_id': prompt.id,
'project_id': prompt.project_id,
'prompt_text': metadata.get('prompt_text', prompt.details),
'related_issue': self._normalize_issue(metadata.get('related_issue')),
'routing': metadata.get('routing') or {},
'timestamp': prompt.created_at.isoformat() if prompt.created_at else None,
}
)
if len(result) >= limit:
break
return result
def get_interpreter_context(self, chat_id: str | None = None, source: str = 'telegram', limit: int = 12) -> dict:
"""Build conversation-aware routing context for the request interpreter."""
recent_chat = self.get_recent_chat_history(chat_id=chat_id, source=source, limit=limit) if chat_id else []
projects = []
for history in self.get_all_projects():
repository = self._get_project_repository(history) or {}
issues = self.get_repository_issues(project_id=history.project_id, state='open', limit=20)
open_pr = self.get_open_pull_request(project_id=history.project_id)
projects.append(
{
'project_id': history.project_id,
'name': history.project_name,
'description': history.description,
'status': history.status,
'repository': {
'owner': repository.get('owner'),
'name': repository.get('name'),
} if repository else None,
'open_pull_request': open_pr,
'open_issues': issues,
}
)
return {
'source': source,
'chat_id': chat_id,
'recent_chat_history': recent_chat,
'projects': projects,
}
def get_all_projects(self) -> list[ProjectHistory]:
"""Get all projects."""
return self.db.query(ProjectHistory).all()
@@ -939,6 +1165,7 @@ class DatabaseManager:
"prompt_text": prompt["prompt_text"],
"features": prompt["features"],
"tech_stack": prompt["tech_stack"],
"related_issue": prompt.get("related_issue"),
"timestamp": prompt["timestamp"],
"changes": prompt["changes"],
"commits": prompt["commits"],
@@ -978,6 +1205,7 @@ class DatabaseManager:
"prompt_text": prompt_metadata.get("prompt_text", current_prompt.details),
"features": prompt_metadata.get("features", []),
"tech_stack": prompt_metadata.get("tech_stack", []),
"related_issue": self._normalize_issue(prompt_metadata.get("related_issue")),
"timestamp": current_prompt.created_at.isoformat() if current_prompt.created_at else None,
"changes": [
{
@@ -1008,6 +1236,7 @@ class DatabaseManager:
"prompt_text": prompt_metadata.get("prompt_text", current_prompt.details),
"features": prompt_metadata.get("features", []),
"tech_stack": prompt_metadata.get("tech_stack", []),
"related_issue": self._normalize_issue(prompt_metadata.get("related_issue")),
"timestamp": current_prompt.created_at.isoformat() if current_prompt.created_at else None,
"changes": [
{
@@ -1040,7 +1269,8 @@ class DatabaseManager:
repository_name: str | None = None,
author_name: str | None = None,
author_email: str | None = None,
commit_parents: list[str] | None = None) -> AuditTrail:
commit_parents: list[str] | None = None,
related_issue: dict | None = None) -> AuditTrail:
"""Log a git commit."""
audit = AuditTrail(
project_id=project_id,
@@ -1068,6 +1298,7 @@ class DatabaseManager:
"author_name": author_name,
"author_email": author_email,
"commit_parents": commit_parents or [],
"related_issue": self._normalize_issue(related_issue),
})
)
self.db.add(audit)
@@ -1335,6 +1566,7 @@ class DatabaseManager:
"author_name": self._normalize_metadata(commit.metadata_json).get("author_name"),
"author_email": self._normalize_metadata(commit.metadata_json).get("author_email"),
"commit_parents": self._normalize_metadata(commit.metadata_json).get("commit_parents", []),
"related_issue": self._normalize_issue(self._normalize_metadata(commit.metadata_json).get("related_issue")),
"timestamp": commit.created_at.isoformat() if commit.created_at else None,
}
for commit in commits
@@ -1355,6 +1587,7 @@ class DatabaseManager:
'PROMPT_RECEIVED',
'LLM_TRACE',
'GIT_COMMIT',
'ISSUE_WORKED',
'PROMPT_REVERTED',
'REPOSITORY_ONBOARDED',
'REPOSITORY_SYNCED',
@@ -1399,6 +1632,14 @@ class DatabaseManager:
if scopes:
metadata['branch_scopes'] = scopes
event_branch_scope = scopes[0] if len(scopes) == 1 else 'mixed'
elif audit.action == 'ISSUE_WORKED':
item_type = 'issue'
issue = self._normalize_issue(metadata.get('issue')) or {}
title = f"Issue #{issue.get('number')} worked on"
scopes = sorted(prompt_branch_scopes.get(metadata.get('prompt_id'), set())) if metadata.get('prompt_id') is not None else []
if scopes:
metadata['branch_scopes'] = scopes
event_branch_scope = scopes[0] if len(scopes) == 1 else 'mixed'
elif audit.action.startswith('PULL_REQUEST_'):
item_type = 'pull_request'
title = f"PR #{metadata.get('pr_number')} {metadata.get('pr_state') or 'updated'}"
@@ -1473,6 +1714,7 @@ class DatabaseManager:
'correlation': correlation,
'related_changes': (correlation or {}).get('changes', []),
'related_llm_traces': (correlation or {}).get('llm_traces', []),
'related_issue': (correlation or {}).get('related_issue') or commit.get('related_issue') or (prompt or {}).get('related_issue'),
'pull_requests': project_bundle.get('pull_requests', []),
'timeline': surrounding_events,
'origin_summary': origin_summary,
@@ -1497,6 +1739,8 @@ class DatabaseManager:
"llm_traces": [],
"prompt_change_correlations": [],
"timeline": [],
"issues": [],
"issue_work": [],
}
# Get logs
@@ -1523,6 +1767,8 @@ class DatabaseManager:
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)
return {
"project": {
@@ -1583,6 +1829,8 @@ class DatabaseManager:
"timeline": timeline,
"repository": repository,
"repository_sync": repository_sync,
"issues": issues,
"issue_work": issue_work,
}
def get_prompt_events(self, project_id: str | None = None, limit: int = 100) -> list[dict]:
@@ -1600,6 +1848,10 @@ class DatabaseManager:
"prompt_text": self._normalize_metadata(prompt.metadata_json).get("prompt_text", prompt.details),
"features": self._normalize_metadata(prompt.metadata_json).get("features", []),
"tech_stack": self._normalize_metadata(prompt.metadata_json).get("tech_stack", []),
"related_issue": self._normalize_issue(self._normalize_metadata(prompt.metadata_json).get("related_issue")),
"source": self._normalize_metadata(prompt.metadata_json).get("source"),
"source_context": self._normalize_metadata(prompt.metadata_json).get("source_context", {}),
"routing": self._normalize_metadata(prompt.metadata_json).get("routing", {}),
"history_id": self._normalize_metadata(prompt.metadata_json).get("history_id"),
"timestamp": prompt.created_at.isoformat() if prompt.created_at else None,
}
@@ -1665,6 +1917,8 @@ class DatabaseManager:
"prompt_events": self.db.query(AuditTrail).filter(AuditTrail.action == "PROMPT_RECEIVED").count(),
"code_changes": self.db.query(AuditTrail).filter(AuditTrail.action == "CODE_CHANGE").count(),
"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],
"system_logs": [

View File

@@ -234,6 +234,40 @@ class GiteaAPI:
_repo = repo or self.repo
return self._request_sync("GET", f"repos/{_owner}/{_repo}/branches")
async def list_issues(
self,
owner: str | None = None,
repo: str | None = None,
state: str = 'open',
) -> dict | list:
"""List repository issues, excluding pull requests at the consumer layer."""
_owner = owner or self.owner
_repo = repo or self.repo
return await self._request("GET", f"repos/{_owner}/{_repo}/issues?state={state}")
def list_issues_sync(
self,
owner: str | None = None,
repo: str | None = None,
state: str = 'open',
) -> dict | list:
"""Synchronously list repository issues."""
_owner = owner or self.owner
_repo = repo or self.repo
return self._request_sync("GET", f"repos/{_owner}/{_repo}/issues?state={state}")
async def get_issue(self, issue_number: int, owner: str | None = None, repo: str | None = None) -> dict:
"""Return one repository issue by number."""
_owner = owner or self.owner
_repo = repo or self.repo
return await self._request("GET", f"repos/{_owner}/{_repo}/issues/{issue_number}")
def get_issue_sync(self, issue_number: int, owner: str | None = None, repo: str | None = None) -> dict:
"""Synchronously return one repository issue by number."""
_owner = owner or self.owner
_repo = repo or self.repo
return self._request_sync("GET", f"repos/{_owner}/{_repo}/issues/{issue_number}")
async def list_repo_commits(
self,
owner: str | None = None,

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import difflib
import py_compile
import re
import subprocess
from typing import Optional
from datetime import datetime
@@ -36,6 +37,9 @@ class AgentOrchestrator:
prompt_text: str | None = None,
prompt_actor: str = "api",
existing_history=None,
prompt_source_context: dict | None = None,
prompt_routing: dict | None = None,
related_issue_hint: dict | None = None,
):
"""Initialize orchestrator."""
self.project_id = project_id
@@ -52,6 +56,8 @@ class AgentOrchestrator:
self.db = db
self.prompt_text = prompt_text
self.prompt_actor = prompt_actor
self.prompt_source_context = prompt_source_context or {}
self.prompt_routing = prompt_routing or {}
self.existing_history = existing_history
self.changed_files: list[str] = []
self.gitea_api = GiteaAPI(
@@ -68,6 +74,9 @@ class AgentOrchestrator:
self.branch_name = self._build_pr_branch_name(project_id)
self.active_pull_request = None
self._gitea_username: str | None = None
hinted_issue_number = (related_issue_hint or {}).get('number') if related_issue_hint else None
self.related_issue_number = hinted_issue_number if hinted_issue_number is not None else self._extract_issue_number(prompt_text)
self.related_issue: dict | None = DatabaseManager._normalize_issue(related_issue_hint)
# Initialize agents
self.git_manager = GitManager(project_id, project_dir=str(self.project_root))
@@ -106,6 +115,9 @@ class AgentOrchestrator:
features=self.features,
tech_stack=self.tech_stack,
actor_name=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,
)
self.ui_manager.ui_data["project_root"] = str(self.project_root)
@@ -118,6 +130,8 @@ class AgentOrchestrator:
"status": "pending" if settings.use_project_repositories else "shared",
"provider": "gitea",
}
if self.related_issue:
self.ui_manager.ui_data["related_issue"] = self.related_issue
if self.active_pull_request:
self.ui_manager.ui_data["pull_request"] = self.active_pull_request
@@ -125,6 +139,13 @@ class AgentOrchestrator:
"""Build a stable branch name used until the PR is merged."""
return f"ai/{project_id}"
def _extract_issue_number(self, prompt_text: str | None) -> int | None:
"""Extract an issue reference from prompt text."""
if not prompt_text:
return None
match = re.search(r'(?:#|issue\s+)(\d+)', prompt_text, flags=re.IGNORECASE)
return int(match.group(1)) if match else None
def _build_repo_url(self, owner: str | None, repo: str | None) -> str | None:
if not owner or not repo or not settings.gitea_url:
return None
@@ -148,6 +169,10 @@ class AgentOrchestrator:
f"Planned files: {', '.join(planned_files)}. "
f"Target branch: {self.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')}."
if self.related_issue else ''
)
),
raw_response={
'planned_files': planned_files,
@@ -155,10 +180,34 @@ class AgentOrchestrator:
'tech_stack': list(self.tech_stack),
'branch': self.branch_name,
'repository': self.ui_manager.ui_data.get('repository', {}),
'related_issue': self.related_issue,
},
fallback_used=False,
)
async def _sync_issue_context(self) -> None:
"""Sync repository issues and resolve a linked issue from the prompt when present."""
if not self.db_manager or not self.history:
return
repository = self.ui_manager.ui_data.get('repository') or {}
owner = repository.get('owner') or self.repo_owner
repo_name = repository.get('name') or self.repo_name
if not owner or not repo_name or not settings.gitea_url or not settings.gitea_token:
return
issues_result = self.db_manager.sync_repository_issues(project_id=self.project_id, gitea_api=self.gitea_api, state='open')
self.ui_manager.ui_data['issues'] = issues_result.get('issues', []) if issues_result.get('status') == 'success' else []
if self.related_issue_number is None:
return
issue_payload = await self.gitea_api.get_issue(issue_number=self.related_issue_number, owner=owner, repo=repo_name)
if isinstance(issue_payload, dict) and issue_payload.get('error'):
return
if issue_payload.get('pull_request'):
return
self.related_issue = DatabaseManager._normalize_issue(issue_payload)
self.ui_manager.ui_data['related_issue'] = self.related_issue
if self.prompt_audit:
self.db_manager.attach_issue_to_prompt(self.prompt_audit.id, self.related_issue)
async def _ensure_remote_repository(self) -> None:
if not settings.use_project_repositories:
self.ui_manager.ui_data["repository"]["status"] = "shared"
@@ -453,6 +502,7 @@ class AgentOrchestrator:
self._append_log("Initializing project.")
await self._ensure_remote_repository()
await self._sync_issue_context()
await self._prepare_git_workspace()
self._log_generation_plan_trace()
@@ -497,6 +547,7 @@ class AgentOrchestrator:
"project_root": str(self.project_root),
"changed_files": list(dict.fromkeys(self.changed_files)),
"repository": self.ui_manager.ui_data.get("repository"),
"related_issue": self.related_issue,
"pull_request": self.ui_manager.ui_data.get("pull_request"),
}
@@ -524,6 +575,7 @@ class AgentOrchestrator:
"project_root": str(self.project_root),
"changed_files": list(dict.fromkeys(self.changed_files)),
"repository": self.ui_manager.ui_data.get("repository"),
"related_issue": self.related_issue,
"pull_request": self.ui_manager.ui_data.get("pull_request"),
}
@@ -604,6 +656,17 @@ class AgentOrchestrator:
commit_url=remote_record.get("commit_url") if remote_record else None,
compare_url=remote_record.get("compare_url") if remote_record else None,
remote_status=remote_record.get("status") if remote_record else "local-only",
related_issue=self.related_issue,
)
if self.related_issue:
self.db_manager.log_issue_work(
project_id=self.project_id,
history_id=self.history.id if self.history else None,
prompt_id=self.prompt_audit.id if self.prompt_audit else None,
issue=self.related_issue,
actor='orchestrator',
commit_hash=commit_hash,
commit_url=remote_record.get('commit_url') if remote_record else None,
)
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
self.ui_manager.ui_data.setdefault("git", {})["error"] = str(exc)

View File

@@ -18,23 +18,35 @@ class RequestInterpreter:
self.ollama_url = (ollama_url or settings.ollama_url).rstrip('/')
self.model = model or settings.OLLAMA_MODEL
async def interpret(self, prompt_text: str) -> dict:
async def interpret(self, prompt_text: str, context: dict | None = None) -> dict:
"""Interpret free-form text into the request shape expected by the orchestrator."""
interpreted, _trace = await self.interpret_with_trace(prompt_text)
interpreted, _trace = await self.interpret_with_trace(prompt_text, context=context)
return interpreted
async def interpret_with_trace(self, prompt_text: str) -> tuple[dict, dict]:
async def interpret_with_trace(self, prompt_text: str, context: dict | None = None) -> tuple[dict, dict]:
"""Interpret free-form text into the request shape expected by the orchestrator."""
normalized = prompt_text.strip()
if not normalized:
raise ValueError('Prompt text cannot be empty')
compact_context = self._build_compact_context(context or {})
system_prompt = (
'You extract structured software requests. '
'Return only JSON with keys name, description, features, tech_stack. '
'name and description must be concise strings. '
'features and tech_stack must be arrays of strings. '
'Infer missing details from the user request instead of leaving arrays empty when possible.'
'You route Telegram software prompts. '
'Decide whether the prompt starts a new project or continues an existing tracked project. '
'When continuing, identify the best matching project_id from the provided context and the issue number if one is mentioned or implied by recent chat history. '
'Return only JSON with keys request and routing. '
'request must contain name, description, features, tech_stack. '
'routing must contain intent, project_id, project_name, issue_number, confidence, and reasoning_summary. '
'Use the provided project catalog and recent chat history. '
'If the user says things like also, continue, work on this, that issue, or follow-up wording, prefer continuation of the most relevant recent project. '
'If the user explicitly asks for a new project, set intent to new_project.'
)
user_prompt = normalized
if compact_context:
user_prompt = (
f"Conversation context:\n{json.dumps(compact_context, indent=2)}\n\n"
f"User prompt:\n{normalized}"
)
try:
@@ -52,7 +64,7 @@ class RequestInterpreter:
'role': 'system',
'content': system_prompt,
},
{'role': 'user', 'content': normalized},
{'role': 'user', 'content': user_prompt},
],
},
) as resp:
@@ -60,38 +72,47 @@ class RequestInterpreter:
if 200 <= resp.status < 300:
content = payload.get('message', {}).get('content', '')
if content:
interpreted = self._normalize_interpreted_request(json.loads(content), normalized)
parsed = json.loads(content)
interpreted = self._normalize_interpreted_request(parsed, normalized)
routing = self._normalize_routing(parsed.get('routing'), interpreted, compact_context)
return interpreted, {
'stage': 'request_interpretation',
'provider': 'ollama',
'model': self.model,
'system_prompt': system_prompt,
'user_prompt': normalized,
'user_prompt': user_prompt,
'assistant_response': content,
'raw_response': payload,
'routing': routing,
'context_excerpt': compact_context,
'fallback_used': False,
}
except Exception:
pass
interpreted = self._heuristic_fallback(normalized)
interpreted, routing = self._heuristic_fallback(normalized, compact_context)
return interpreted, {
'stage': 'request_interpretation',
'provider': 'heuristic',
'model': self.model,
'system_prompt': system_prompt,
'user_prompt': normalized,
'assistant_response': json.dumps(interpreted),
'user_prompt': user_prompt,
'assistant_response': json.dumps({'request': interpreted, 'routing': routing}),
'raw_response': {'fallback': 'heuristic'},
'routing': routing,
'context_excerpt': compact_context,
'fallback_used': True,
}
def _normalize_interpreted_request(self, interpreted: dict, original_prompt: str) -> 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)
description = str(interpreted.get('description') or '').strip() or original_prompt[:255]
features = self._normalize_list(interpreted.get('features'))
tech_stack = self._normalize_list(interpreted.get('tech_stack'))
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']
return {
@@ -101,6 +122,57 @@ class RequestInterpreter:
'tech_stack': tech_stack,
}
def _build_compact_context(self, context: dict) -> dict:
"""Reduce interpreter context to the fields that help routing."""
projects = []
for project in context.get('projects', [])[:10]:
issues = []
for issue in project.get('open_issues', [])[:5]:
issues.append({'number': issue.get('number'), 'title': issue.get('title'), 'state': issue.get('state')})
projects.append(
{
'project_id': project.get('project_id'),
'name': project.get('name'),
'description': project.get('description'),
'repository': project.get('repository'),
'open_pull_request': bool(project.get('open_pull_request')),
'open_issues': issues,
}
)
return {
'chat_id': context.get('chat_id'),
'recent_chat_history': context.get('recent_chat_history', [])[:8],
'projects': projects,
}
def _normalize_routing(self, routing: dict | None, interpreted: dict, context: dict) -> dict:
"""Normalize routing metadata returned by the LLM."""
routing = routing or {}
project_id = routing.get('project_id')
project_name = routing.get('project_name')
issue_number = routing.get('issue_number')
if issue_number in ('', None):
issue_number = None
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')
return {
'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')),
'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'),
}
def _normalize_list(self, value) -> list[str]:
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
@@ -115,7 +187,7 @@ class RequestInterpreter:
words = [word.capitalize() for word in cleaned.split()[:4]]
return ' '.join(words) or 'Generated Project'
def _heuristic_fallback(self, prompt_text: str) -> dict:
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 = [
@@ -124,9 +196,54 @@ class RequestInterpreter:
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']
return {
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),
'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 _extract_issue_number(self, prompt_text: str) -> int | None:
match = re.search(r'(?:#|issue\s+)(\d+)', prompt_text, flags=re.IGNORECASE)
return int(match.group(1)) if match else None

View File

@@ -113,6 +113,60 @@ def _render_repository_sync_block(repository_sync: dict | None) -> None:
ui.label(str(repository_sync['error'])).classes('factory-code')
def _render_issue_chip(issue: dict | None) -> None:
"""Render one linked issue as a compact chip/link."""
if not issue:
return
label = f"#{issue.get('number')} {issue.get('title') or ''}".strip()
if issue.get('url'):
ui.link(label, issue['url'], new_tab=True).classes('factory-chip')
else:
ui.label(label).classes('factory-chip')
def _render_issue_list(issues: list[dict]) -> None:
"""Render tracked repository issues."""
if not issues:
ui.label('No tracked issues recorded for this repository.').classes('factory-muted')
return
for issue in issues:
with ui.card().classes('q-pa-sm q-mt-sm'):
with ui.row().classes('items-center justify-between w-full'):
with ui.row().classes('items-center gap-2'):
_render_issue_chip(issue)
if issue.get('state'):
ui.label(issue['state']).classes('factory-chip')
if issue.get('timestamp'):
ui.label(issue['timestamp']).classes('factory-muted')
if issue.get('labels'):
ui.label(', '.join(issue['labels'])).classes('factory-muted')
if issue.get('body'):
with ui.expansion('Issue details').classes('w-full q-mt-sm'):
ui.label(issue['body']).classes('factory-code')
def _render_issue_work_events(events: list[dict]) -> None:
"""Render issue-work audit events showing when issues were worked on."""
if not events:
ui.label('No issue-work events recorded yet.').classes('factory-muted')
return
for event in events:
issue = event.get('issue') or {}
with ui.card().classes('q-pa-sm q-mt-sm'):
with ui.row().classes('items-center justify-between w-full'):
with ui.row().classes('items-center gap-2'):
_render_issue_chip(issue)
ui.label('worked').classes('factory-chip')
ui.label(event.get('timestamp') or 'Timestamp unavailable').classes('factory-muted')
with ui.row().classes('items-center gap-2 q-mt-sm'):
if event.get('prompt_id') is not None:
ui.label(f"prompt {event['prompt_id']}").classes('factory-chip')
if event.get('commit_hash'):
ui.label(event['commit_hash'][:12]).classes('factory-chip')
if event.get('commit_url'):
ui.link('Open commit', event['commit_url'], new_tab=True)
def _render_commit_list(commits: list[dict]) -> None:
"""Render prompt- or project-level git commits."""
if not commits:
@@ -208,6 +262,9 @@ def _render_commit_context(context: dict | None) -> None:
if prompt:
ui.label('Originating Prompt').style('font-weight: 700; color: #3a281a; margin-top: 12px;')
ui.label(prompt.get('prompt_text') or 'Prompt text unavailable').classes('factory-code')
if context.get('related_issue'):
ui.label('Linked Issue').style('font-weight: 700; color: #3a281a; margin-top: 12px;')
_render_issue_chip(context.get('related_issue'))
if context.get('related_llm_traces'):
ui.label('Related LLM Trace').style('font-weight: 700; color: #3a281a; margin-top: 12px;')
_render_llm_traces(context.get('related_llm_traces', []))
@@ -271,6 +328,7 @@ def _run_background_repository_sync() -> None:
if not repository.get('owner') or not repository.get('name'):
continue
manager.sync_repository_activity(project_id=history.project_id, gitea_api=gitea_api, commit_limit=20)
manager.sync_repository_issues(project_id=history.project_id, gitea_api=gitea_api, state='open')
synced_any = True
if synced_any:
manager.log_system_event(component='gitea', level='INFO', message='Background repository sync completed')
@@ -358,6 +416,9 @@ def _render_prompt_compare(correlation: dict) -> None:
if changed_files:
ui.label('Files in this prompt change set').style('font-weight: 700; color: #3a281a; margin-top: 12px;')
ui.label(', '.join(changed_files)).classes('factory-muted')
if correlation.get('related_issue'):
ui.label('Linked Issue').style('font-weight: 700; color: #3a281a; margin-top: 12px;')
_render_issue_chip(correlation.get('related_issue'))
ui.label('Commits').style('font-weight: 700; color: #3a281a; margin-top: 12px;')
_render_commit_list(commits)
ui.label('LLM Trace').style('font-weight: 700; color: #3a281a; margin-top: 12px;')
@@ -641,6 +702,7 @@ def create_dashboard():
manager = DatabaseManager(db)
onboarded = manager.onboard_repository(owner=owner, repo_name=repo_name, repository_data=repo_info)
sync_result = manager.sync_repository_activity(project_id=onboarded['project_id'], gitea_api=gitea_api, commit_limit=25)
manager.sync_repository_issues(project_id=onboarded['project_id'], gitea_api=gitea_api, state='open')
manager.log_system_event(
component='gitea',
level='INFO' if sync_result.get('status') == 'success' else 'ERROR',
@@ -659,16 +721,19 @@ def create_dashboard():
ui.notify('Database session could not be created', color='negative')
return
with closing(db):
result = DatabaseManager(db).sync_repository_activity(
project_id=project_id,
gitea_api=GiteaAPI(
manager = DatabaseManager(db)
gitea_api = GiteaAPI(
token=settings.GITEA_TOKEN,
base_url=settings.GITEA_URL,
owner=settings.GITEA_OWNER,
repo=settings.GITEA_REPO or '',
),
)
result = manager.sync_repository_activity(
project_id=project_id,
gitea_api=gitea_api,
commit_limit=25,
)
manager.sync_repository_issues(project_id=project_id, gitea_api=gitea_api, state='open')
ui.notify(result.get('message', 'Repository sync finished'), color='positive' if result.get('status') == 'success' else 'negative')
dashboard_body.refresh()
@@ -875,6 +940,8 @@ def create_dashboard():
prompt = prompts[0]
ui.markdown(f"**Requested features:** {', '.join(prompt['features']) or 'None'}")
ui.markdown(f"**Tech stack:** {', '.join(prompt['tech_stack']) or 'None'}")
if prompt.get('related_issue'):
_render_issue_chip(prompt.get('related_issue'))
ui.label(prompt['prompt_text']).classes('factory-code')
else:
ui.label('No prompt recorded.').classes('factory-muted')
@@ -884,6 +951,10 @@ def create_dashboard():
ui.label('Generated Changes').style('font-weight: 700; color: #3a281a;')
_render_change_list(project_bundle.get('code_changes', []))
with ui.card().classes('q-pa-md'):
ui.label('Tracked Issues').style('font-weight: 700; color: #3a281a;')
_render_issue_list(project_bundle.get('issues', []))
with ui.grid(columns=2).classes('w-full gap-4 q-pa-md'):
with ui.card().classes('q-pa-md'):
ui.label('Git Commits').style('font-weight: 700; color: #3a281a;')
@@ -903,6 +974,10 @@ def create_dashboard():
ui.label('No project logs yet.').classes('factory-muted')
with ui.grid(columns=1).classes('w-full gap-4 q-pa-md'):
with ui.card().classes('q-pa-md'):
ui.label('Issue Work').style('font-weight: 700; color: #3a281a;')
_render_issue_work_events(project_bundle.get('issue_work', []))
with ui.card().classes('q-pa-md'):
ui.label('Audit Trail').style('font-weight: 700; color: #3a281a;')
audits = project_bundle.get('audit_trail', [])[:6]
@@ -942,6 +1017,8 @@ def create_dashboard():
ui.label(correlation_project.get('project_name') or correlation['project_id']).style('font-size: 1rem; font-weight: 700; color: #2f241d;')
_render_repository_block(correlation_project.get('repository'))
_render_pull_request_block(correlation_project.get('pull_request'))
if correlation.get('related_issue'):
_render_issue_chip(correlation.get('related_issue'))
ui.label(correlation['prompt_text']).classes('factory-code q-mt-sm')
if correlation.get('revert'):
ui.label(f"Reverted by commit {correlation['revert'].get('revert_commit_hash', 'unknown')[:12]}").classes('factory-chip')
@@ -987,6 +1064,8 @@ def create_dashboard():
ui.label(correlation_project.get('project_name') or correlation['project_id']).style('font-size: 1rem; font-weight: 700; color: #2f241d;')
_render_repository_block(correlation_project.get('repository'))
_render_pull_request_block(correlation_project.get('pull_request'))
if correlation.get('related_issue'):
_render_issue_chip(correlation.get('related_issue'))
with ui.row().classes('items-center gap-2 q-mt-sm'):
if correlation.get('revert'):
ui.label('Prompt has already been reverted').classes('factory-chip')
@@ -1100,6 +1179,7 @@ def create_dashboard():
'/audit/projects',
'/audit/prompts',
'/audit/changes',
'/audit/issues',
'/audit/commit-context',
'/audit/timeline',
'/audit/llm-traces',

View File

@@ -173,12 +173,16 @@ async def _run_generation(
db: Session,
prompt_text: str | None = None,
prompt_actor: str = 'api',
prompt_source_context: dict | None = None,
prompt_routing: dict | None = None,
preferred_project_id: str | None = None,
related_issue: dict | None = None,
) -> dict:
"""Run the shared generation pipeline for a structured request."""
database_module.init_db()
manager = DatabaseManager(db)
reusable_history = manager.get_latest_project_by_name(request.name)
reusable_history = manager.get_project_by_id(preferred_project_id) if preferred_project_id else 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
@@ -193,7 +197,9 @@ async def _run_generation(
),
project_id=reusable_history.project_id,
)
if reusable_history and manager.get_open_pull_request(project_id=reusable_history.project_id):
if preferred_project_id and reusable_history is not None:
project_id = reusable_history.project_id
elif 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)
@@ -209,6 +215,9 @@ async def _run_generation(
prompt_text=resolved_prompt_text,
prompt_actor=prompt_actor,
existing_history=reusable_history,
prompt_source_context=prompt_source_context,
prompt_routing=prompt_routing,
related_issue_hint=related_issue,
)
result = await orchestrator.run()
@@ -229,6 +238,7 @@ 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')
response_data['related_issue'] = result.get('related_issue') or (result.get('ui_data') or {}).get('related_issue')
response_data['pull_request'] = result.get('pull_request') or manager.get_open_pull_request(project_id=project_id)
summary_context = {
'name': response_data['name'],
@@ -245,6 +255,7 @@ async def _run_generation(
'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,
'related_issue': response_data.get('related_issue'),
'message': response_data.get('message'),
'logs': [log.get('message', '') for log in response_data.get('logs', []) if isinstance(log, dict)],
}
@@ -320,6 +331,7 @@ def read_api_info():
'/audit/system/logs',
'/audit/prompts',
'/audit/changes',
'/audit/issues',
'/audit/commit-context',
'/audit/timeline',
'/audit/llm-traces',
@@ -373,13 +385,30 @@ async def generate_software_from_text(request: FreeformSoftwareRequest, db: DbSe
},
}
interpreted, interpretation_trace = await RequestInterpreter().interpret_with_trace(request.prompt_text)
manager = DatabaseManager(db)
interpreter_context = manager.get_interpreter_context(chat_id=request.chat_id, source=request.source)
interpreted, interpretation_trace = await RequestInterpreter().interpret_with_trace(
request.prompt_text,
context=interpreter_context,
)
routing = interpretation_trace.get('routing') or {}
selected_history = manager.get_project_by_id(routing.get('project_id')) if routing.get('project_id') else None
if selected_history is not None and routing.get('intent') != 'new_project':
interpreted['name'] = selected_history.project_name
interpreted['description'] = selected_history.description or interpreted['description']
structured_request = SoftwareRequest(**interpreted)
response = await _run_generation(
structured_request,
db,
prompt_text=request.prompt_text,
prompt_actor=request.source,
prompt_source_context={
'chat_id': request.chat_id,
'chat_type': request.chat_type,
},
prompt_routing=routing,
preferred_project_id=routing.get('project_id') if routing.get('intent') != 'new_project' else None,
related_issue={'number': routing.get('issue_number')} if routing.get('issue_number') is not None else None,
)
project_data = response.get('data', {})
if project_data.get('history_id') is not None:
@@ -400,6 +429,7 @@ async def generate_software_from_text(request: FreeformSoftwareRequest, db: DbSe
fallback_used=interpretation_trace.get('fallback_used', False),
)
response['interpreted_request'] = interpreted
response['routing'] = routing
response['llm_trace'] = interpretation_trace
response['source'] = {
'type': request.source,
@@ -456,6 +486,20 @@ 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/issues')
def get_issue_audit(
db: DbSession,
project_id: str | None = Query(default=None),
state: str | None = Query(default=None),
):
"""Return tracked repository issues and issue-work events."""
manager = DatabaseManager(db)
return {
'issues': manager.get_repository_issues(project_id=project_id, state=state),
'issue_work': manager.get_issue_work_events(project_id=project_id),
}
@app.get('/audit/commit-context')
def get_commit_context_audit(
db: DbSession,
@@ -538,9 +582,11 @@ async def undo_prompt_changes(project_id: str, prompt_id: int, db: DbSession):
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)
gitea_api = _create_gitea_api()
result = manager.sync_repository_activity(project_id=project_id, gitea_api=gitea_api, commit_limit=commit_limit)
if result.get('status') == 'error':
raise HTTPException(status_code=400, detail=result.get('message', 'Repository sync failed'))
manager.sync_repository_issues(project_id=project_id, gitea_api=gitea_api, state='open')
return result
@@ -582,6 +628,7 @@ async def onboard_gitea_repository(request: GiteaRepositoryOnboardRequest, db: D
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)
manager.sync_repository_issues(project_id=onboarded['project_id'], gitea_api=gitea_api, state='open')
sync_result = None
if request.sync_commits:
sync_result = manager.sync_repository_activity(