diff --git a/ai_software_factory/agents/database_manager.py b/ai_software_factory/agents/database_manager.py index e5ff4ed..3667d34 100644 --- a/ai_software_factory/agents/database_manager.py +++ b/ai_software_factory/agents/database_manager.py @@ -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": [ diff --git a/ai_software_factory/agents/gitea.py b/ai_software_factory/agents/gitea.py index 99dab51..4927296 100644 --- a/ai_software_factory/agents/gitea.py +++ b/ai_software_factory/agents/gitea.py @@ -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, diff --git a/ai_software_factory/agents/orchestrator.py b/ai_software_factory/agents/orchestrator.py index de10941..7be34e2 100644 --- a/ai_software_factory/agents/orchestrator.py +++ b/ai_software_factory/agents/orchestrator.py @@ -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,7 +656,18 @@ 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) self._append_log(f"Git commit skipped: {exc}") diff --git a/ai_software_factory/agents/request_interpreter.py b/ai_software_factory/agents/request_interpreter.py index 3856d83..f74530d 100644 --- a/ai_software_factory/agents/request_interpreter.py +++ b/ai_software_factory/agents/request_interpreter.py @@ -18,24 +18,36 @@ 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: import aiohttp @@ -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, - } \ No newline at end of file + } + 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 \ No newline at end of file diff --git a/ai_software_factory/dashboard_ui.py b/ai_software_factory/dashboard_ui.py index 64f77fd..71974ac 100644 --- a/ai_software_factory/dashboard_ui.py +++ b/ai_software_factory/dashboard_ui.py @@ -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( + 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=GiteaAPI( - token=settings.GITEA_TOKEN, - base_url=settings.GITEA_URL, - owner=settings.GITEA_OWNER, - repo=settings.GITEA_REPO or '', - ), + 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', diff --git a/ai_software_factory/main.py b/ai_software_factory/main.py index 4244a1d..29ef2f9 100644 --- a/ai_software_factory/main.py +++ b/ai_software_factory/main.py @@ -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(