feat: gitea issue integration, refs NOISSUE
This commit is contained in:
@@ -33,6 +33,7 @@ except ImportError:
|
|||||||
)
|
)
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
class DatabaseMigrations:
|
class DatabaseMigrations:
|
||||||
@@ -134,6 +135,41 @@ class DatabaseManager:
|
|||||||
return 'pr'
|
return 'pr'
|
||||||
return 'manual'
|
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:
|
def log_project_start(self, project_id: str, project_name: str, description: str) -> ProjectHistory:
|
||||||
"""Log project start."""
|
"""Log project start."""
|
||||||
history = ProjectHistory(
|
history = ProjectHistory(
|
||||||
@@ -170,6 +206,9 @@ class DatabaseManager:
|
|||||||
actor_name: str = "api",
|
actor_name: str = "api",
|
||||||
actor_type: str = "user",
|
actor_type: str = "user",
|
||||||
source: str = "generate-endpoint",
|
source: str = "generate-endpoint",
|
||||||
|
related_issue: dict | None = None,
|
||||||
|
source_context: dict | None = None,
|
||||||
|
routing: dict | None = None,
|
||||||
) -> AuditTrail | None:
|
) -> AuditTrail | None:
|
||||||
"""Persist the originating prompt so later code changes can be correlated to it."""
|
"""Persist the originating prompt so later code changes can be correlated to it."""
|
||||||
history = self.db.query(ProjectHistory).filter(ProjectHistory.id == history_id).first()
|
history = self.db.query(ProjectHistory).filter(ProjectHistory.id == history_id).first()
|
||||||
@@ -194,6 +233,9 @@ class DatabaseManager:
|
|||||||
"features": feature_list,
|
"features": feature_list,
|
||||||
"tech_stack": tech_list,
|
"tech_stack": tech_list,
|
||||||
"source": source,
|
"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,
|
"features": feature_list,
|
||||||
"tech_stack": tech_list,
|
"tech_stack": tech_list,
|
||||||
"source": source,
|
"source": source,
|
||||||
|
"related_issue": self._normalize_issue(related_issue),
|
||||||
|
"source_context": source_context or {},
|
||||||
|
"routing": routing or {},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.db.add(audit)
|
self.db.add(audit)
|
||||||
@@ -217,6 +262,129 @@ class DatabaseManager:
|
|||||||
self.db.refresh(audit)
|
self.db.refresh(audit)
|
||||||
return 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(
|
def log_llm_trace(
|
||||||
self,
|
self,
|
||||||
project_id: str,
|
project_id: str,
|
||||||
@@ -649,6 +817,64 @@ class DatabaseManager:
|
|||||||
"""Get project by ID."""
|
"""Get project by ID."""
|
||||||
return self.db.query(ProjectHistory).filter(ProjectHistory.project_id == project_id).first()
|
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]:
|
def get_all_projects(self) -> list[ProjectHistory]:
|
||||||
"""Get all projects."""
|
"""Get all projects."""
|
||||||
return self.db.query(ProjectHistory).all()
|
return self.db.query(ProjectHistory).all()
|
||||||
@@ -939,6 +1165,7 @@ class DatabaseManager:
|
|||||||
"prompt_text": prompt["prompt_text"],
|
"prompt_text": prompt["prompt_text"],
|
||||||
"features": prompt["features"],
|
"features": prompt["features"],
|
||||||
"tech_stack": prompt["tech_stack"],
|
"tech_stack": prompt["tech_stack"],
|
||||||
|
"related_issue": prompt.get("related_issue"),
|
||||||
"timestamp": prompt["timestamp"],
|
"timestamp": prompt["timestamp"],
|
||||||
"changes": prompt["changes"],
|
"changes": prompt["changes"],
|
||||||
"commits": prompt["commits"],
|
"commits": prompt["commits"],
|
||||||
@@ -978,6 +1205,7 @@ class DatabaseManager:
|
|||||||
"prompt_text": prompt_metadata.get("prompt_text", current_prompt.details),
|
"prompt_text": prompt_metadata.get("prompt_text", current_prompt.details),
|
||||||
"features": prompt_metadata.get("features", []),
|
"features": prompt_metadata.get("features", []),
|
||||||
"tech_stack": prompt_metadata.get("tech_stack", []),
|
"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,
|
"timestamp": current_prompt.created_at.isoformat() if current_prompt.created_at else None,
|
||||||
"changes": [
|
"changes": [
|
||||||
{
|
{
|
||||||
@@ -1008,6 +1236,7 @@ class DatabaseManager:
|
|||||||
"prompt_text": prompt_metadata.get("prompt_text", current_prompt.details),
|
"prompt_text": prompt_metadata.get("prompt_text", current_prompt.details),
|
||||||
"features": prompt_metadata.get("features", []),
|
"features": prompt_metadata.get("features", []),
|
||||||
"tech_stack": prompt_metadata.get("tech_stack", []),
|
"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,
|
"timestamp": current_prompt.created_at.isoformat() if current_prompt.created_at else None,
|
||||||
"changes": [
|
"changes": [
|
||||||
{
|
{
|
||||||
@@ -1040,7 +1269,8 @@ class DatabaseManager:
|
|||||||
repository_name: str | None = None,
|
repository_name: str | None = None,
|
||||||
author_name: str | None = None,
|
author_name: str | None = None,
|
||||||
author_email: 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."""
|
"""Log a git commit."""
|
||||||
audit = AuditTrail(
|
audit = AuditTrail(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
@@ -1068,6 +1298,7 @@ class DatabaseManager:
|
|||||||
"author_name": author_name,
|
"author_name": author_name,
|
||||||
"author_email": author_email,
|
"author_email": author_email,
|
||||||
"commit_parents": commit_parents or [],
|
"commit_parents": commit_parents or [],
|
||||||
|
"related_issue": self._normalize_issue(related_issue),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
self.db.add(audit)
|
self.db.add(audit)
|
||||||
@@ -1335,6 +1566,7 @@ class DatabaseManager:
|
|||||||
"author_name": self._normalize_metadata(commit.metadata_json).get("author_name"),
|
"author_name": self._normalize_metadata(commit.metadata_json).get("author_name"),
|
||||||
"author_email": self._normalize_metadata(commit.metadata_json).get("author_email"),
|
"author_email": self._normalize_metadata(commit.metadata_json).get("author_email"),
|
||||||
"commit_parents": self._normalize_metadata(commit.metadata_json).get("commit_parents", []),
|
"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,
|
"timestamp": commit.created_at.isoformat() if commit.created_at else None,
|
||||||
}
|
}
|
||||||
for commit in commits
|
for commit in commits
|
||||||
@@ -1355,6 +1587,7 @@ class DatabaseManager:
|
|||||||
'PROMPT_RECEIVED',
|
'PROMPT_RECEIVED',
|
||||||
'LLM_TRACE',
|
'LLM_TRACE',
|
||||||
'GIT_COMMIT',
|
'GIT_COMMIT',
|
||||||
|
'ISSUE_WORKED',
|
||||||
'PROMPT_REVERTED',
|
'PROMPT_REVERTED',
|
||||||
'REPOSITORY_ONBOARDED',
|
'REPOSITORY_ONBOARDED',
|
||||||
'REPOSITORY_SYNCED',
|
'REPOSITORY_SYNCED',
|
||||||
@@ -1399,6 +1632,14 @@ class DatabaseManager:
|
|||||||
if scopes:
|
if scopes:
|
||||||
metadata['branch_scopes'] = scopes
|
metadata['branch_scopes'] = scopes
|
||||||
event_branch_scope = scopes[0] if len(scopes) == 1 else 'mixed'
|
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_'):
|
elif audit.action.startswith('PULL_REQUEST_'):
|
||||||
item_type = 'pull_request'
|
item_type = 'pull_request'
|
||||||
title = f"PR #{metadata.get('pr_number')} {metadata.get('pr_state') or 'updated'}"
|
title = f"PR #{metadata.get('pr_number')} {metadata.get('pr_state') or 'updated'}"
|
||||||
@@ -1473,6 +1714,7 @@ class DatabaseManager:
|
|||||||
'correlation': correlation,
|
'correlation': correlation,
|
||||||
'related_changes': (correlation or {}).get('changes', []),
|
'related_changes': (correlation or {}).get('changes', []),
|
||||||
'related_llm_traces': (correlation or {}).get('llm_traces', []),
|
'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', []),
|
'pull_requests': project_bundle.get('pull_requests', []),
|
||||||
'timeline': surrounding_events,
|
'timeline': surrounding_events,
|
||||||
'origin_summary': origin_summary,
|
'origin_summary': origin_summary,
|
||||||
@@ -1497,6 +1739,8 @@ class DatabaseManager:
|
|||||||
"llm_traces": [],
|
"llm_traces": [],
|
||||||
"prompt_change_correlations": [],
|
"prompt_change_correlations": [],
|
||||||
"timeline": [],
|
"timeline": [],
|
||||||
|
"issues": [],
|
||||||
|
"issue_work": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get logs
|
# Get logs
|
||||||
@@ -1523,6 +1767,8 @@ class DatabaseManager:
|
|||||||
repository = self._get_project_repository(history)
|
repository = self._get_project_repository(history)
|
||||||
timeline = self.get_project_timeline(project_id=project_id)
|
timeline = self.get_project_timeline(project_id=project_id)
|
||||||
repository_sync = self.get_repository_sync_status(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 {
|
return {
|
||||||
"project": {
|
"project": {
|
||||||
@@ -1583,6 +1829,8 @@ class DatabaseManager:
|
|||||||
"timeline": timeline,
|
"timeline": timeline,
|
||||||
"repository": repository,
|
"repository": repository,
|
||||||
"repository_sync": repository_sync,
|
"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]:
|
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),
|
"prompt_text": self._normalize_metadata(prompt.metadata_json).get("prompt_text", prompt.details),
|
||||||
"features": self._normalize_metadata(prompt.metadata_json).get("features", []),
|
"features": self._normalize_metadata(prompt.metadata_json).get("features", []),
|
||||||
"tech_stack": self._normalize_metadata(prompt.metadata_json).get("tech_stack", []),
|
"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"),
|
"history_id": self._normalize_metadata(prompt.metadata_json).get("history_id"),
|
||||||
"timestamp": prompt.created_at.isoformat() if prompt.created_at else None,
|
"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(),
|
"prompt_events": self.db.query(AuditTrail).filter(AuditTrail.action == "PROMPT_RECEIVED").count(),
|
||||||
"code_changes": self.db.query(AuditTrail).filter(AuditTrail.action == "CODE_CHANGE").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(),
|
"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],
|
"projects": [self.get_project_audit_data(project.project_id) for project in projects],
|
||||||
"system_logs": [
|
"system_logs": [
|
||||||
|
|||||||
@@ -234,6 +234,40 @@ class GiteaAPI:
|
|||||||
_repo = repo or self.repo
|
_repo = repo or self.repo
|
||||||
return self._request_sync("GET", f"repos/{_owner}/{_repo}/branches")
|
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(
|
async def list_repo_commits(
|
||||||
self,
|
self,
|
||||||
owner: str | None = None,
|
owner: str | None = None,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import difflib
|
import difflib
|
||||||
import py_compile
|
import py_compile
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -36,6 +37,9 @@ class AgentOrchestrator:
|
|||||||
prompt_text: str | None = None,
|
prompt_text: str | None = None,
|
||||||
prompt_actor: str = "api",
|
prompt_actor: str = "api",
|
||||||
existing_history=None,
|
existing_history=None,
|
||||||
|
prompt_source_context: dict | None = None,
|
||||||
|
prompt_routing: dict | None = None,
|
||||||
|
related_issue_hint: dict | None = None,
|
||||||
):
|
):
|
||||||
"""Initialize orchestrator."""
|
"""Initialize orchestrator."""
|
||||||
self.project_id = project_id
|
self.project_id = project_id
|
||||||
@@ -52,6 +56,8 @@ class AgentOrchestrator:
|
|||||||
self.db = db
|
self.db = db
|
||||||
self.prompt_text = prompt_text
|
self.prompt_text = prompt_text
|
||||||
self.prompt_actor = prompt_actor
|
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.existing_history = existing_history
|
||||||
self.changed_files: list[str] = []
|
self.changed_files: list[str] = []
|
||||||
self.gitea_api = GiteaAPI(
|
self.gitea_api = GiteaAPI(
|
||||||
@@ -68,6 +74,9 @@ class AgentOrchestrator:
|
|||||||
self.branch_name = self._build_pr_branch_name(project_id)
|
self.branch_name = self._build_pr_branch_name(project_id)
|
||||||
self.active_pull_request = None
|
self.active_pull_request = None
|
||||||
self._gitea_username: str | None = 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
|
# Initialize agents
|
||||||
self.git_manager = GitManager(project_id, project_dir=str(self.project_root))
|
self.git_manager = GitManager(project_id, project_dir=str(self.project_root))
|
||||||
@@ -106,6 +115,9 @@ class AgentOrchestrator:
|
|||||||
features=self.features,
|
features=self.features,
|
||||||
tech_stack=self.tech_stack,
|
tech_stack=self.tech_stack,
|
||||||
actor_name=self.prompt_actor,
|
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)
|
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",
|
"status": "pending" if settings.use_project_repositories else "shared",
|
||||||
"provider": "gitea",
|
"provider": "gitea",
|
||||||
}
|
}
|
||||||
|
if self.related_issue:
|
||||||
|
self.ui_manager.ui_data["related_issue"] = self.related_issue
|
||||||
if self.active_pull_request:
|
if self.active_pull_request:
|
||||||
self.ui_manager.ui_data["pull_request"] = 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."""
|
"""Build a stable branch name used until the PR is merged."""
|
||||||
return f"ai/{project_id}"
|
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:
|
def _build_repo_url(self, owner: str | None, repo: str | None) -> str | None:
|
||||||
if not owner or not repo or not settings.gitea_url:
|
if not owner or not repo or not settings.gitea_url:
|
||||||
return None
|
return None
|
||||||
@@ -148,6 +169,10 @@ class AgentOrchestrator:
|
|||||||
f"Planned files: {', '.join(planned_files)}. "
|
f"Planned files: {', '.join(planned_files)}. "
|
||||||
f"Target branch: {self.branch_name}. "
|
f"Target branch: {self.branch_name}. "
|
||||||
f"Repository mode: {self.ui_manager.ui_data.get('repository', {}).get('mode', 'unknown')}."
|
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={
|
raw_response={
|
||||||
'planned_files': planned_files,
|
'planned_files': planned_files,
|
||||||
@@ -155,10 +180,34 @@ class AgentOrchestrator:
|
|||||||
'tech_stack': list(self.tech_stack),
|
'tech_stack': list(self.tech_stack),
|
||||||
'branch': self.branch_name,
|
'branch': self.branch_name,
|
||||||
'repository': self.ui_manager.ui_data.get('repository', {}),
|
'repository': self.ui_manager.ui_data.get('repository', {}),
|
||||||
|
'related_issue': self.related_issue,
|
||||||
},
|
},
|
||||||
fallback_used=False,
|
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:
|
async def _ensure_remote_repository(self) -> None:
|
||||||
if not settings.use_project_repositories:
|
if not settings.use_project_repositories:
|
||||||
self.ui_manager.ui_data["repository"]["status"] = "shared"
|
self.ui_manager.ui_data["repository"]["status"] = "shared"
|
||||||
@@ -453,6 +502,7 @@ class AgentOrchestrator:
|
|||||||
self._append_log("Initializing project.")
|
self._append_log("Initializing project.")
|
||||||
|
|
||||||
await self._ensure_remote_repository()
|
await self._ensure_remote_repository()
|
||||||
|
await self._sync_issue_context()
|
||||||
await self._prepare_git_workspace()
|
await self._prepare_git_workspace()
|
||||||
self._log_generation_plan_trace()
|
self._log_generation_plan_trace()
|
||||||
|
|
||||||
@@ -497,6 +547,7 @@ class AgentOrchestrator:
|
|||||||
"project_root": str(self.project_root),
|
"project_root": str(self.project_root),
|
||||||
"changed_files": list(dict.fromkeys(self.changed_files)),
|
"changed_files": list(dict.fromkeys(self.changed_files)),
|
||||||
"repository": self.ui_manager.ui_data.get("repository"),
|
"repository": self.ui_manager.ui_data.get("repository"),
|
||||||
|
"related_issue": self.related_issue,
|
||||||
"pull_request": self.ui_manager.ui_data.get("pull_request"),
|
"pull_request": self.ui_manager.ui_data.get("pull_request"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,6 +575,7 @@ class AgentOrchestrator:
|
|||||||
"project_root": str(self.project_root),
|
"project_root": str(self.project_root),
|
||||||
"changed_files": list(dict.fromkeys(self.changed_files)),
|
"changed_files": list(dict.fromkeys(self.changed_files)),
|
||||||
"repository": self.ui_manager.ui_data.get("repository"),
|
"repository": self.ui_manager.ui_data.get("repository"),
|
||||||
|
"related_issue": self.related_issue,
|
||||||
"pull_request": self.ui_manager.ui_data.get("pull_request"),
|
"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,
|
commit_url=remote_record.get("commit_url") if remote_record else None,
|
||||||
compare_url=remote_record.get("compare_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",
|
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:
|
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
||||||
self.ui_manager.ui_data.setdefault("git", {})["error"] = str(exc)
|
self.ui_manager.ui_data.setdefault("git", {})["error"] = str(exc)
|
||||||
|
|||||||
@@ -18,23 +18,35 @@ class RequestInterpreter:
|
|||||||
self.ollama_url = (ollama_url or settings.ollama_url).rstrip('/')
|
self.ollama_url = (ollama_url or settings.ollama_url).rstrip('/')
|
||||||
self.model = model or settings.OLLAMA_MODEL
|
self.model = model or settings.OLLAMA_MODEL
|
||||||
|
|
||||||
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."""
|
"""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
|
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."""
|
"""Interpret free-form text into the request shape expected by the orchestrator."""
|
||||||
normalized = prompt_text.strip()
|
normalized = prompt_text.strip()
|
||||||
if not normalized:
|
if not normalized:
|
||||||
raise ValueError('Prompt text cannot be empty')
|
raise ValueError('Prompt text cannot be empty')
|
||||||
|
|
||||||
|
compact_context = self._build_compact_context(context or {})
|
||||||
|
|
||||||
system_prompt = (
|
system_prompt = (
|
||||||
'You extract structured software requests. '
|
'You route Telegram software prompts. '
|
||||||
'Return only JSON with keys name, description, features, tech_stack. '
|
'Decide whether the prompt starts a new project or continues an existing tracked project. '
|
||||||
'name and description must be concise strings. '
|
'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. '
|
||||||
'features and tech_stack must be arrays of strings. '
|
'Return only JSON with keys request and routing. '
|
||||||
'Infer missing details from the user request instead of leaving arrays empty when possible.'
|
'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:
|
try:
|
||||||
@@ -52,7 +64,7 @@ class RequestInterpreter:
|
|||||||
'role': 'system',
|
'role': 'system',
|
||||||
'content': system_prompt,
|
'content': system_prompt,
|
||||||
},
|
},
|
||||||
{'role': 'user', 'content': normalized},
|
{'role': 'user', 'content': user_prompt},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
) as resp:
|
) as resp:
|
||||||
@@ -60,38 +72,47 @@ class RequestInterpreter:
|
|||||||
if 200 <= resp.status < 300:
|
if 200 <= resp.status < 300:
|
||||||
content = payload.get('message', {}).get('content', '')
|
content = payload.get('message', {}).get('content', '')
|
||||||
if 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, {
|
return interpreted, {
|
||||||
'stage': 'request_interpretation',
|
'stage': 'request_interpretation',
|
||||||
'provider': 'ollama',
|
'provider': 'ollama',
|
||||||
'model': self.model,
|
'model': self.model,
|
||||||
'system_prompt': system_prompt,
|
'system_prompt': system_prompt,
|
||||||
'user_prompt': normalized,
|
'user_prompt': user_prompt,
|
||||||
'assistant_response': content,
|
'assistant_response': content,
|
||||||
'raw_response': payload,
|
'raw_response': payload,
|
||||||
|
'routing': routing,
|
||||||
|
'context_excerpt': compact_context,
|
||||||
'fallback_used': False,
|
'fallback_used': False,
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
interpreted = self._heuristic_fallback(normalized)
|
interpreted, routing = self._heuristic_fallback(normalized, compact_context)
|
||||||
return interpreted, {
|
return interpreted, {
|
||||||
'stage': 'request_interpretation',
|
'stage': 'request_interpretation',
|
||||||
'provider': 'heuristic',
|
'provider': 'heuristic',
|
||||||
'model': self.model,
|
'model': self.model,
|
||||||
'system_prompt': system_prompt,
|
'system_prompt': system_prompt,
|
||||||
'user_prompt': normalized,
|
'user_prompt': user_prompt,
|
||||||
'assistant_response': json.dumps(interpreted),
|
'assistant_response': json.dumps({'request': interpreted, 'routing': routing}),
|
||||||
'raw_response': {'fallback': 'heuristic'},
|
'raw_response': {'fallback': 'heuristic'},
|
||||||
|
'routing': routing,
|
||||||
|
'context_excerpt': compact_context,
|
||||||
'fallback_used': True,
|
'fallback_used': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _normalize_interpreted_request(self, interpreted: dict, original_prompt: str) -> dict:
|
def _normalize_interpreted_request(self, interpreted: dict, original_prompt: str) -> dict:
|
||||||
"""Normalize LLM output into the required request shape."""
|
"""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)
|
name = str(interpreted.get('name') or '').strip() or self._derive_name(original_prompt)
|
||||||
description = str(interpreted.get('description') or '').strip() or original_prompt[:255]
|
if isinstance(request_payload, dict):
|
||||||
features = self._normalize_list(interpreted.get('features'))
|
name = str(request_payload.get('name') or '').strip() or self._derive_name(original_prompt)
|
||||||
tech_stack = self._normalize_list(interpreted.get('tech_stack'))
|
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:
|
if not features:
|
||||||
features = ['core workflow based on free-form request']
|
features = ['core workflow based on free-form request']
|
||||||
return {
|
return {
|
||||||
@@ -101,6 +122,57 @@ class RequestInterpreter:
|
|||||||
'tech_stack': tech_stack,
|
'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]:
|
def _normalize_list(self, value) -> list[str]:
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return [str(item).strip() for item in value if str(item).strip()]
|
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]]
|
words = [word.capitalize() for word in cleaned.split()[:4]]
|
||||||
return ' '.join(words) or 'Generated Project'
|
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."""
|
"""Fallback request extraction when Ollama is unavailable."""
|
||||||
lowered = prompt_text.lower()
|
lowered = prompt_text.lower()
|
||||||
tech_candidates = [
|
tech_candidates = [
|
||||||
@@ -124,9 +196,54 @@ class RequestInterpreter:
|
|||||||
tech_stack = [candidate for candidate in tech_candidates if candidate in lowered]
|
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()]
|
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']
|
features = sentences[:3] or ['Implement the user request from free-form text']
|
||||||
return {
|
interpreted = {
|
||||||
'name': self._derive_name(prompt_text),
|
'name': self._derive_name(prompt_text),
|
||||||
'description': sentences[0][:255] if sentences else prompt_text[:255],
|
'description': sentences[0][:255] if sentences else prompt_text[:255],
|
||||||
'features': features,
|
'features': features,
|
||||||
'tech_stack': tech_stack,
|
'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
|
||||||
@@ -113,6 +113,60 @@ def _render_repository_sync_block(repository_sync: dict | None) -> None:
|
|||||||
ui.label(str(repository_sync['error'])).classes('factory-code')
|
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:
|
def _render_commit_list(commits: list[dict]) -> None:
|
||||||
"""Render prompt- or project-level git commits."""
|
"""Render prompt- or project-level git commits."""
|
||||||
if not commits:
|
if not commits:
|
||||||
@@ -208,6 +262,9 @@ def _render_commit_context(context: dict | None) -> None:
|
|||||||
if prompt:
|
if prompt:
|
||||||
ui.label('Originating Prompt').style('font-weight: 700; color: #3a281a; margin-top: 12px;')
|
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')
|
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'):
|
if context.get('related_llm_traces'):
|
||||||
ui.label('Related LLM Trace').style('font-weight: 700; color: #3a281a; margin-top: 12px;')
|
ui.label('Related LLM Trace').style('font-weight: 700; color: #3a281a; margin-top: 12px;')
|
||||||
_render_llm_traces(context.get('related_llm_traces', []))
|
_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'):
|
if not repository.get('owner') or not repository.get('name'):
|
||||||
continue
|
continue
|
||||||
manager.sync_repository_activity(project_id=history.project_id, gitea_api=gitea_api, commit_limit=20)
|
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
|
synced_any = True
|
||||||
if synced_any:
|
if synced_any:
|
||||||
manager.log_system_event(component='gitea', level='INFO', message='Background repository sync completed')
|
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:
|
if changed_files:
|
||||||
ui.label('Files in this prompt change set').style('font-weight: 700; color: #3a281a; margin-top: 12px;')
|
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')
|
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;')
|
ui.label('Commits').style('font-weight: 700; color: #3a281a; margin-top: 12px;')
|
||||||
_render_commit_list(commits)
|
_render_commit_list(commits)
|
||||||
ui.label('LLM Trace').style('font-weight: 700; color: #3a281a; margin-top: 12px;')
|
ui.label('LLM Trace').style('font-weight: 700; color: #3a281a; margin-top: 12px;')
|
||||||
@@ -641,6 +702,7 @@ def create_dashboard():
|
|||||||
manager = DatabaseManager(db)
|
manager = DatabaseManager(db)
|
||||||
onboarded = manager.onboard_repository(owner=owner, repo_name=repo_name, repository_data=repo_info)
|
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)
|
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(
|
manager.log_system_event(
|
||||||
component='gitea',
|
component='gitea',
|
||||||
level='INFO' if sync_result.get('status') == 'success' else 'ERROR',
|
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')
|
ui.notify('Database session could not be created', color='negative')
|
||||||
return
|
return
|
||||||
with closing(db):
|
with closing(db):
|
||||||
result = DatabaseManager(db).sync_repository_activity(
|
manager = DatabaseManager(db)
|
||||||
project_id=project_id,
|
gitea_api = GiteaAPI(
|
||||||
gitea_api=GiteaAPI(
|
|
||||||
token=settings.GITEA_TOKEN,
|
token=settings.GITEA_TOKEN,
|
||||||
base_url=settings.GITEA_URL,
|
base_url=settings.GITEA_URL,
|
||||||
owner=settings.GITEA_OWNER,
|
owner=settings.GITEA_OWNER,
|
||||||
repo=settings.GITEA_REPO or '',
|
repo=settings.GITEA_REPO or '',
|
||||||
),
|
)
|
||||||
|
result = manager.sync_repository_activity(
|
||||||
|
project_id=project_id,
|
||||||
|
gitea_api=gitea_api,
|
||||||
commit_limit=25,
|
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')
|
ui.notify(result.get('message', 'Repository sync finished'), color='positive' if result.get('status') == 'success' else 'negative')
|
||||||
dashboard_body.refresh()
|
dashboard_body.refresh()
|
||||||
|
|
||||||
@@ -875,6 +940,8 @@ def create_dashboard():
|
|||||||
prompt = prompts[0]
|
prompt = prompts[0]
|
||||||
ui.markdown(f"**Requested features:** {', '.join(prompt['features']) or 'None'}")
|
ui.markdown(f"**Requested features:** {', '.join(prompt['features']) or 'None'}")
|
||||||
ui.markdown(f"**Tech stack:** {', '.join(prompt['tech_stack']) 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')
|
ui.label(prompt['prompt_text']).classes('factory-code')
|
||||||
else:
|
else:
|
||||||
ui.label('No prompt recorded.').classes('factory-muted')
|
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;')
|
ui.label('Generated Changes').style('font-weight: 700; color: #3a281a;')
|
||||||
_render_change_list(project_bundle.get('code_changes', []))
|
_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.grid(columns=2).classes('w-full gap-4 q-pa-md'):
|
||||||
with ui.card().classes('q-pa-md'):
|
with ui.card().classes('q-pa-md'):
|
||||||
ui.label('Git Commits').style('font-weight: 700; color: #3a281a;')
|
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')
|
ui.label('No project logs yet.').classes('factory-muted')
|
||||||
|
|
||||||
with ui.grid(columns=1).classes('w-full gap-4 q-pa-md'):
|
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'):
|
with ui.card().classes('q-pa-md'):
|
||||||
ui.label('Audit Trail').style('font-weight: 700; color: #3a281a;')
|
ui.label('Audit Trail').style('font-weight: 700; color: #3a281a;')
|
||||||
audits = project_bundle.get('audit_trail', [])[:6]
|
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;')
|
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_repository_block(correlation_project.get('repository'))
|
||||||
_render_pull_request_block(correlation_project.get('pull_request'))
|
_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')
|
ui.label(correlation['prompt_text']).classes('factory-code q-mt-sm')
|
||||||
if correlation.get('revert'):
|
if correlation.get('revert'):
|
||||||
ui.label(f"Reverted by commit {correlation['revert'].get('revert_commit_hash', 'unknown')[:12]}").classes('factory-chip')
|
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;')
|
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_repository_block(correlation_project.get('repository'))
|
||||||
_render_pull_request_block(correlation_project.get('pull_request'))
|
_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'):
|
with ui.row().classes('items-center gap-2 q-mt-sm'):
|
||||||
if correlation.get('revert'):
|
if correlation.get('revert'):
|
||||||
ui.label('Prompt has already been reverted').classes('factory-chip')
|
ui.label('Prompt has already been reverted').classes('factory-chip')
|
||||||
@@ -1100,6 +1179,7 @@ def create_dashboard():
|
|||||||
'/audit/projects',
|
'/audit/projects',
|
||||||
'/audit/prompts',
|
'/audit/prompts',
|
||||||
'/audit/changes',
|
'/audit/changes',
|
||||||
|
'/audit/issues',
|
||||||
'/audit/commit-context',
|
'/audit/commit-context',
|
||||||
'/audit/timeline',
|
'/audit/timeline',
|
||||||
'/audit/llm-traces',
|
'/audit/llm-traces',
|
||||||
|
|||||||
@@ -173,12 +173,16 @@ async def _run_generation(
|
|||||||
db: Session,
|
db: Session,
|
||||||
prompt_text: str | None = None,
|
prompt_text: str | None = None,
|
||||||
prompt_actor: str = 'api',
|
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:
|
) -> dict:
|
||||||
"""Run the shared generation pipeline for a structured request."""
|
"""Run the shared generation pipeline for a structured request."""
|
||||||
database_module.init_db()
|
database_module.init_db()
|
||||||
|
|
||||||
manager = DatabaseManager(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:
|
if reusable_history and database_module.settings.gitea_url and database_module.settings.gitea_token:
|
||||||
try:
|
try:
|
||||||
from .agents.gitea import GiteaAPI
|
from .agents.gitea import GiteaAPI
|
||||||
@@ -193,7 +197,9 @@ async def _run_generation(
|
|||||||
),
|
),
|
||||||
project_id=reusable_history.project_id,
|
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
|
project_id = reusable_history.project_id
|
||||||
else:
|
else:
|
||||||
project_id = _build_project_id(request.name)
|
project_id = _build_project_id(request.name)
|
||||||
@@ -209,6 +215,9 @@ async def _run_generation(
|
|||||||
prompt_text=resolved_prompt_text,
|
prompt_text=resolved_prompt_text,
|
||||||
prompt_actor=prompt_actor,
|
prompt_actor=prompt_actor,
|
||||||
existing_history=reusable_history,
|
existing_history=reusable_history,
|
||||||
|
prompt_source_context=prompt_source_context,
|
||||||
|
prompt_routing=prompt_routing,
|
||||||
|
related_issue_hint=related_issue,
|
||||||
)
|
)
|
||||||
result = await orchestrator.run()
|
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['project_root'] = result.get('project_root', str(_project_root(project_id)))
|
||||||
response_data['changed_files'] = result.get('changed_files', [])
|
response_data['changed_files'] = result.get('changed_files', [])
|
||||||
response_data['repository'] = result.get('repository')
|
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)
|
response_data['pull_request'] = result.get('pull_request') or manager.get_open_pull_request(project_id=project_id)
|
||||||
summary_context = {
|
summary_context = {
|
||||||
'name': response_data['name'],
|
'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,
|
'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_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,
|
'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'),
|
'message': response_data.get('message'),
|
||||||
'logs': [log.get('message', '') for log in response_data.get('logs', []) if isinstance(log, dict)],
|
'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/system/logs',
|
||||||
'/audit/prompts',
|
'/audit/prompts',
|
||||||
'/audit/changes',
|
'/audit/changes',
|
||||||
|
'/audit/issues',
|
||||||
'/audit/commit-context',
|
'/audit/commit-context',
|
||||||
'/audit/timeline',
|
'/audit/timeline',
|
||||||
'/audit/llm-traces',
|
'/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)
|
structured_request = SoftwareRequest(**interpreted)
|
||||||
response = await _run_generation(
|
response = await _run_generation(
|
||||||
structured_request,
|
structured_request,
|
||||||
db,
|
db,
|
||||||
prompt_text=request.prompt_text,
|
prompt_text=request.prompt_text,
|
||||||
prompt_actor=request.source,
|
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', {})
|
project_data = response.get('data', {})
|
||||||
if project_data.get('history_id') is not None:
|
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),
|
fallback_used=interpretation_trace.get('fallback_used', False),
|
||||||
)
|
)
|
||||||
response['interpreted_request'] = interpreted
|
response['interpreted_request'] = interpreted
|
||||||
|
response['routing'] = routing
|
||||||
response['llm_trace'] = interpretation_trace
|
response['llm_trace'] = interpretation_trace
|
||||||
response['source'] = {
|
response['source'] = {
|
||||||
'type': request.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)]}
|
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')
|
@app.get('/audit/commit-context')
|
||||||
def get_commit_context_audit(
|
def get_commit_context_audit(
|
||||||
db: DbSession,
|
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)):
|
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."""
|
"""Import recent repository activity from Gitea for a tracked project."""
|
||||||
manager = DatabaseManager(db)
|
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':
|
if result.get('status') == 'error':
|
||||||
raise HTTPException(status_code=400, detail=result.get('message', 'Repository sync failed'))
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -582,6 +628,7 @@ async def onboard_gitea_repository(request: GiteaRepositoryOnboardRequest, db: D
|
|||||||
raise HTTPException(status_code=404, detail=repo.get('error'))
|
raise HTTPException(status_code=404, detail=repo.get('error'))
|
||||||
manager = DatabaseManager(db)
|
manager = DatabaseManager(db)
|
||||||
onboarded = manager.onboard_repository(owner=owner, repo_name=request.repo_name, repository_data=repo)
|
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
|
sync_result = None
|
||||||
if request.sync_commits:
|
if request.sync_commits:
|
||||||
sync_result = manager.sync_repository_activity(
|
sync_result = manager.sync_repository_activity(
|
||||||
|
|||||||
Reference in New Issue
Block a user