6 Commits

Author SHA1 Message Date
634f4326c6 release: version 0.9.12 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 17s
Upload Python Package / deploy (push) Successful in 34s
2026-04-11 20:31:22 +02:00
f54d3b3b7a fix: remove heuristic decision making fallbacks, refs NOISSUE 2026-04-11 20:31:19 +02:00
c147d8be78 release: version 0.9.11 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 17s
Upload Python Package / deploy (push) Successful in 34s
2026-04-11 20:09:34 +02:00
9ffaa18efe fix: project association improvements, refs NOISSUE 2026-04-11 20:09:31 +02:00
d53f3fe207 release: version 0.9.10 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 10s
Upload Python Package / deploy (push) Successful in 31s
2026-04-11 18:05:25 +02:00
4f1d757dd8 fix: more git integration fixes, refs NOISSUE 2026-04-11 18:05:20 +02:00
8 changed files with 235 additions and 268 deletions

View File

@@ -5,10 +5,44 @@ Changelog
(unreleased) (unreleased)
------------ ------------
Fix
~~~
- Remove heuristic decision making fallbacks, refs NOISSUE. [Simon
Diesenreiter]
0.9.11 (2026-04-11)
-------------------
Fix
~~~
- Project association improvements, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.9.10 (2026-04-11)
-------------------
Fix
~~~
- More git integration fixes, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.9.9 (2026-04-11)
------------------
Fix Fix
~~~ ~~~
- Add missing git binary, refs NOISSUE. [Simon Diesenreiter] - Add missing git binary, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.9.8 (2026-04-11) 0.9.8 (2026-04-11)
------------------ ------------------

View File

@@ -1 +1 @@
0.9.9 0.9.12

View File

@@ -2308,6 +2308,10 @@ class DatabaseManager:
if commit.get('remote_status') == 'pushed' or commit.get('imported_from_remote') or commit.get('commit_url') if commit.get('remote_status') == 'pushed' or commit.get('imported_from_remote') or commit.get('commit_url')
] ]
has_pull_request = any(pr.get('pr_state') == 'open' and not pr.get('merged') for pr in pull_requests) has_pull_request = any(pr.get('pr_state') == 'open' and not pr.get('merged') for pr in pull_requests)
published_non_main_commits = [
commit for commit in published_commits
if (commit.get('branch') or '').strip() not in {'', 'main', 'master'}
]
if orphan_code_changes: if orphan_code_changes:
delivery_status = 'uncommitted' delivery_status = 'uncommitted'
delivery_message = ( delivery_message = (
@@ -2320,12 +2324,15 @@ class DatabaseManager:
f"{len(local_only_code_changes)} generated file change(s) were committed only in the local workspace. " f"{len(local_only_code_changes)} generated file change(s) were committed only in the local workspace. "
"No remote repo push was recorded for this prompt yet." "No remote repo push was recorded for this prompt yet."
) )
elif published_commits and repository and repository.get('mode') == 'project' and not has_pull_request: elif published_non_main_commits and repository and repository.get('mode') == 'project' and not has_pull_request:
delivery_status = 'pushed_no_pr' delivery_status = 'pushed_no_pr'
delivery_message = 'Changes were pushed to the remote repository, but no pull request is currently tracked for review.' delivery_message = 'Changes were pushed to the remote repository, but no pull request is currently tracked for review.'
elif published_commits: elif published_commits:
delivery_status = 'delivered' delivery_status = 'delivered'
if has_pull_request:
delivery_message = 'Generated changes were published to the tracked repository and are reviewable through the recorded pull request.' delivery_message = 'Generated changes were published to the tracked repository and are reviewable through the recorded pull request.'
else:
delivery_message = 'Generated changes were published directly to the tracked repository default branch.'
else: else:
delivery_status = 'pending' delivery_status = 'pending'
delivery_message = 'No git commit has been recorded for this project yet.' delivery_message = 'No git commit has been recorded for this project yet.'

View File

@@ -58,6 +58,18 @@ class GiteaAPI:
"""Build a Gitea API URL from a relative path.""" """Build a Gitea API URL from a relative path."""
return f"{self.base_url}/api/v1/{path.lstrip('/')}" return f"{self.base_url}/api/v1/{path.lstrip('/')}"
def _normalize_pull_request_head(self, head: str | None, owner: str | None = None) -> str | None:
"""Return a Gitea-compatible head ref for pull request creation."""
normalized = (head or '').strip()
if not normalized:
return None
if ':' in normalized:
return normalized
effective_owner = (owner or self.owner or '').strip()
if not effective_owner:
return normalized
return f"{effective_owner}:{normalized}"
def build_repo_git_url(self, owner: str | None = None, repo: str | None = None) -> str | None: def build_repo_git_url(self, owner: str | None = None, repo: str | None = None) -> str | None:
"""Build the clone URL for a repository.""" """Build the clone URL for a repository."""
_owner = owner or self.owner _owner = owner or self.owner
@@ -222,11 +234,12 @@ class GiteaAPI:
"""Create a pull request.""" """Create a pull request."""
_owner = owner or self.owner _owner = owner or self.owner
_repo = repo or self.repo _repo = repo or self.repo
normalized_head = self._normalize_pull_request_head(head, _owner)
payload = { payload = {
"title": title, "title": title,
"body": body, "body": body,
"base": base, "base": base,
"head": head or f"{_owner}-{_repo}-ai-gen-{hash(title) % 10000}", "head": normalized_head or f"{_owner}:{_owner}-{_repo}-ai-gen-{hash(title) % 10000}",
} }
return await self._request("POST", f"repos/{_owner}/{_repo}/pulls", payload) return await self._request("POST", f"repos/{_owner}/{_repo}/pulls", payload)
@@ -242,11 +255,12 @@ class GiteaAPI:
"""Synchronously create a pull request.""" """Synchronously create a pull request."""
_owner = owner or self.owner _owner = owner or self.owner
_repo = repo or self.repo _repo = repo or self.repo
normalized_head = self._normalize_pull_request_head(head, _owner)
payload = { payload = {
"title": title, "title": title,
"body": body, "body": body,
"base": base, "base": base,
"head": head or f"{_owner}-{_repo}-ai-gen-{hash(title) % 10000}", "head": normalized_head or f"{_owner}:{_owner}-{_repo}-ai-gen-{hash(title) % 10000}",
} }
return self._request_sync("POST", f"repos/{_owner}/{_repo}/pulls", payload) return self._request_sync("POST", f"repos/{_owner}/{_repo}/pulls", payload)

View File

@@ -124,7 +124,9 @@ class AgentOrchestrator:
self.repo_name = repository.get('name') or self.repo_name self.repo_name = repository.get('name') or self.repo_name
self.repo_url = repository.get('url') or self.repo_url self.repo_url = repository.get('url') or self.repo_url
git_state = latest_ui.get('git') if isinstance(latest_ui.get('git'), dict) else {} git_state = latest_ui.get('git') if isinstance(latest_ui.get('git'), dict) else {}
self.branch_name = git_state.get('active_branch') or self.branch_name persisted_active_branch = git_state.get('active_branch')
if persisted_active_branch and persisted_active_branch not in {'main', 'master'}:
self.branch_name = persisted_active_branch
if self.prompt_text: if self.prompt_text:
self.prompt_audit = self.db_manager.log_prompt_submission( self.prompt_audit = self.db_manager.log_prompt_submission(
history_id=self.history.id, history_id=self.history.id,
@@ -133,6 +135,7 @@ 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,
source=self.prompt_actor,
related_issue={'number': self.related_issue_number} if self.related_issue_number is not None else None, related_issue={'number': self.related_issue_number} if self.related_issue_number is not None else None,
source_context=self.prompt_source_context, source_context=self.prompt_source_context,
routing=self.prompt_routing, routing=self.prompt_routing,
@@ -167,38 +170,18 @@ class AgentOrchestrator:
".gitignore": "__pycache__/\n*.pyc\n.venv/\n.pytest_cache/\n.mypy_cache/\n", ".gitignore": "__pycache__/\n*.pyc\n.venv/\n.pytest_cache/\n.mypy_cache/\n",
} }
def _fallback_generated_files(self) -> dict[str, str]:
"""Deterministic fallback files when LLM generation is unavailable."""
feature_section = "\n".join(f"- {feature}" for feature in self.features) or "- None specified"
tech_section = "\n".join(f"- {tech}" for tech in self.tech_stack) or "- Python"
return {
"README.md": (
f"# {self.project_name}\n\n"
f"{self.description}\n\n"
"## Features\n"
f"{feature_section}\n\n"
"## Tech Stack\n"
f"{tech_section}\n"
),
"requirements.txt": "fastapi\nuvicorn\npytest\n",
"main.py": (
"from fastapi import FastAPI\n\n"
"app = FastAPI(title=\"Generated App\")\n\n"
"@app.get('/')\n"
"def read_root():\n"
f" return {{'name': '{self.project_name}', 'status': 'generated', 'features': {self.features!r}}}\n"
),
"tests/test_app.py": (
"from main import read_root\n\n"
"def test_read_root():\n"
f" assert read_root()['name'] == '{self.project_name}'\n"
),
}
def _build_pr_branch_name(self, project_id: str) -> str: def _build_pr_branch_name(self, project_id: str) -> str:
"""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 _should_use_pull_request_flow(self) -> bool:
"""Return whether this run should deliver changes through a PR branch."""
return self.existing_history is not None or self.active_pull_request is not None
def _delivery_branch_name(self) -> str:
"""Return the git branch used for the current delivery."""
return self.branch_name if self._should_use_pull_request_flow() else 'main'
def _extract_issue_number(self, prompt_text: str | None) -> int | None: def _extract_issue_number(self, prompt_text: str | None) -> int | None:
"""Extract an issue reference from prompt text.""" """Extract an issue reference from prompt text."""
if not prompt_text: if not prompt_text:
@@ -215,7 +198,7 @@ class AgentOrchestrator:
"""Persist the current generation plan as an inspectable trace.""" """Persist the current generation plan as an inspectable trace."""
if not self.db_manager or not self.history or not self.prompt_audit: if not self.db_manager or not self.history or not self.prompt_audit:
return return
planned_files = list(self._static_files().keys()) + list(self._fallback_generated_files().keys()) planned_files = list(self._static_files().keys()) + ['README.md', 'requirements.txt', 'main.py', 'tests/test_app.py']
self.db_manager.log_llm_trace( self.db_manager.log_llm_trace(
project_id=self.project_id, project_id=self.project_id,
history_id=self.history.id, history_id=self.history.id,
@@ -227,7 +210,7 @@ class AgentOrchestrator:
user_prompt=self.prompt_text or self.description, user_prompt=self.prompt_text or self.description,
assistant_response=( assistant_response=(
f"Planned files: {', '.join(planned_files)}. " f"Planned files: {', '.join(planned_files)}. "
f"Target branch: {self.branch_name}. " f"Target branch: {self._delivery_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')}." f" Linked issue: #{self.related_issue.get('number')} {self.related_issue.get('title')}."
@@ -238,7 +221,7 @@ class AgentOrchestrator:
'planned_files': planned_files, 'planned_files': planned_files,
'features': list(self.features), 'features': list(self.features),
'tech_stack': list(self.tech_stack), 'tech_stack': list(self.tech_stack),
'branch': self.branch_name, 'branch': self._delivery_branch_name(),
'repository': self.ui_manager.ui_data.get('repository', {}), 'repository': self.ui_manager.ui_data.get('repository', {}),
'related_issue': self.related_issue, 'related_issue': self.related_issue,
}, },
@@ -334,7 +317,6 @@ class AgentOrchestrator:
async def _generate_prompt_driven_files(self) -> tuple[dict[str, str], dict | None, bool]: async def _generate_prompt_driven_files(self) -> tuple[dict[str, str], dict | None, bool]:
"""Use the configured LLM to generate prompt-specific project files.""" """Use the configured LLM to generate prompt-specific project files."""
fallback_files = self._fallback_generated_files()
workspace_context = self._collect_workspace_context() workspace_context = self._collect_workspace_context()
has_existing_files = bool(workspace_context.get('has_existing_files')) has_existing_files = bool(workspace_context.get('has_existing_files'))
if has_existing_files: if has_existing_files:
@@ -409,10 +391,16 @@ class AgentOrchestrator:
f"raw={raw_generated_paths or []}; accepted={accepted_paths or []}; rejected={rejected_paths or []}; " f"raw={raw_generated_paths or []}; accepted={accepted_paths or []}; rejected={rejected_paths or []}; "
f"existing_workspace={has_existing_files}", f"existing_workspace={has_existing_files}",
) )
if has_existing_files: if not content:
return generated_files, trace, True raise RuntimeError('LLM code generation did not return a usable response.')
merged_files = {**fallback_files, **generated_files} if not generated_files:
return merged_files, trace, False raise RuntimeError('LLM code generation did not return any writable files.')
if not has_existing_files:
required_files = {'README.md', 'requirements.txt', 'main.py', 'tests/test_app.py'}
missing_files = sorted(required_files - set(generated_files))
if missing_files:
raise RuntimeError(f"LLM code generation omitted required starter files: {', '.join(missing_files)}")
return generated_files, trace, has_existing_files
async def _sync_issue_context(self) -> None: async def _sync_issue_context(self) -> None:
"""Sync repository issues and resolve a linked issue from the prompt when present.""" """Sync repository issues and resolve a linked issue from the prompt when present."""
@@ -572,11 +560,15 @@ class AgentOrchestrator:
self.ui_manager.ui_data.setdefault('git', {})['remote_error'] = str(exc) self.ui_manager.ui_data.setdefault('git', {})['remote_error'] = str(exc)
self._append_log(f'Initial main push skipped: {exc}') self._append_log(f'Initial main push skipped: {exc}')
delivery_branch = self._delivery_branch_name()
if self._should_use_pull_request_flow():
if self.git_manager.branch_exists(self.branch_name): if self.git_manager.branch_exists(self.branch_name):
self.git_manager.checkout_branch(self.branch_name) self.git_manager.checkout_branch(self.branch_name)
else: else:
self.git_manager.checkout_branch(self.branch_name, create=True, start_point='main') self.git_manager.checkout_branch(self.branch_name, create=True, start_point='main')
self.ui_manager.ui_data.setdefault('git', {})['active_branch'] = self.branch_name else:
self.git_manager.checkout_branch('main')
self.ui_manager.ui_data.setdefault('git', {})['active_branch'] = delivery_branch
async def _ensure_pull_request(self) -> dict | None: async def _ensure_pull_request(self) -> dict | None:
"""Create the project pull request on first delivery and reuse it later.""" """Create the project pull request on first delivery and reuse it later."""
@@ -593,6 +585,16 @@ class AgentOrchestrator:
f"Prompt: {self.prompt_text or self.description}\n\n" f"Prompt: {self.prompt_text or self.description}\n\n"
f"Branch: {self.branch_name}" f"Branch: {self.branch_name}"
) )
pull_request_debug = self.ui_manager.ui_data.setdefault('git', {}).setdefault('pull_request_debug', {})
pull_request_request = {
'owner': self.repo_owner,
'repo': self.repo_name,
'title': title,
'body': body,
'base': 'main',
'head': self.gitea_api._normalize_pull_request_head(self.branch_name, self.repo_owner) or self.branch_name,
}
pull_request_debug['request'] = pull_request_request
result = await self.gitea_api.create_pull_request( result = await self.gitea_api.create_pull_request(
title=title, title=title,
body=body, body=body,
@@ -601,7 +603,9 @@ class AgentOrchestrator:
base='main', base='main',
head=self.branch_name, head=self.branch_name,
) )
pull_request_debug['response'] = result
if result.get('error'): if result.get('error'):
pull_request_debug['status'] = 'error'
raise RuntimeError(f"Unable to create pull request: {result.get('error')}") raise RuntimeError(f"Unable to create pull request: {result.get('error')}")
pr_number = result.get('number') or result.get('id') or 0 pr_number = result.get('number') or result.get('id') or 0
@@ -616,6 +620,8 @@ class AgentOrchestrator:
'merged': bool(result.get('merged')), 'merged': bool(result.get('merged')),
'pr_state': result.get('state', 'open'), 'pr_state': result.get('state', 'open'),
} }
pull_request_debug['status'] = 'created'
pull_request_debug['resolved'] = pr_data
if self.db_manager and self.history: if self.db_manager and self.history:
self.db_manager.save_pr_data(self.history.id, pr_data) self.db_manager.save_pr_data(self.history.id, pr_data)
self.active_pull_request = self.db_manager.get_open_pull_request(project_id=self.project_id) if self.db_manager else pr_data self.active_pull_request = self.db_manager.get_open_pull_request(project_id=self.project_id) if self.db_manager else pr_data
@@ -627,16 +633,17 @@ class AgentOrchestrator:
repository = self.ui_manager.ui_data.get("repository") or {} repository = self.ui_manager.ui_data.get("repository") or {}
if not self._repository_supports_remote_delivery(repository): if not self._repository_supports_remote_delivery(repository):
return None return None
push_result = await self._push_branch(self.branch_name) delivery_branch = self._delivery_branch_name()
push_result = await self._push_branch(delivery_branch)
if push_result is None: if push_result is None:
return None return None
pull_request = await self._ensure_pull_request() pull_request = await self._ensure_pull_request() if self._should_use_pull_request_flow() else None
commit_url = self.gitea_api.build_commit_url(commit_hash, owner=self.repo_owner, repo=self.repo_name) commit_url = self.gitea_api.build_commit_url(commit_hash, owner=self.repo_owner, repo=self.repo_name)
compare_url = self.gitea_api.build_compare_url(base_commit, commit_hash, owner=self.repo_owner, repo=self.repo_name) if base_commit else None compare_url = self.gitea_api.build_compare_url(base_commit, commit_hash, owner=self.repo_owner, repo=self.repo_name) if base_commit else None
remote_record = { remote_record = {
"status": "pushed", "status": "pushed",
"remote": push_result.get('remote'), "remote": push_result.get('remote'),
"branch": self.branch_name, "branch": delivery_branch,
"commit_url": commit_url, "commit_url": commit_url,
"compare_url": compare_url, "compare_url": compare_url,
"changed_files": changed_files, "changed_files": changed_files,
@@ -646,7 +653,10 @@ class AgentOrchestrator:
repository["last_commit_url"] = commit_url repository["last_commit_url"] = commit_url
if compare_url: if compare_url:
repository["last_compare_url"] = compare_url repository["last_compare_url"] = compare_url
self._append_log(f"Pushed generated commit to {self.repo_owner}/{self.repo_name}.") if pull_request:
self._append_log(f"Pushed generated commit to {self.repo_owner}/{self.repo_name} and updated the delivery pull request.")
else:
self._append_log(f"Pushed generated commit directly to {self.repo_owner}/{self.repo_name} on {delivery_branch}.")
return remote_record return remote_record
def _build_diff_text(self, relative_path: str, previous_content: str, new_content: str) -> str: def _build_diff_text(self, relative_path: str, previous_content: str, new_content: str) -> str:
@@ -830,7 +840,7 @@ class AgentOrchestrator:
self._write_file(relative_path, content) self._write_file(relative_path, content)
if editing_existing_workspace and len(self.pending_code_changes) == change_count_before: if editing_existing_workspace and len(self.pending_code_changes) == change_count_before:
raise RuntimeError('The LLM response did not produce any file changes for the existing project.') raise RuntimeError('The LLM response did not produce any file changes for the existing project.')
fallback_used = bool(trace and trace.get('fallback_used')) or trace is None fallback_used = bool(trace and trace.get('fallback_used'))
if self.db_manager and self.history and self.prompt_audit and trace: if self.db_manager and self.history and self.prompt_audit and trace:
self.db_manager.log_llm_trace( self.db_manager.log_llm_trace(
project_id=self.project_id, project_id=self.project_id,
@@ -845,9 +855,6 @@ class AgentOrchestrator:
raw_response=trace.get('raw_response'), raw_response=trace.get('raw_response'),
fallback_used=fallback_used, fallback_used=fallback_used,
) )
if fallback_used:
self._append_log('LLM code generation was unavailable; used deterministic scaffolding fallback.')
else:
self._append_log('Application files generated from the prompt with the configured LLM.') self._append_log('Application files generated from the prompt with the configured LLM.')
async def _run_tests(self) -> None: async def _run_tests(self) -> None:
@@ -912,7 +919,7 @@ class AgentOrchestrator:
"files": unique_files, "files": unique_files,
"timestamp": datetime.utcnow().isoformat(), "timestamp": datetime.utcnow().isoformat(),
"scope": "local", "scope": "local",
"branch": self.branch_name, "branch": self._delivery_branch_name(),
} }
git_debug.update({ git_debug.update({
'commit_status': 'committed', 'commit_status': 'committed',

View File

@@ -28,9 +28,6 @@ class RequestInterpreter:
GENERIC_PROJECT_NAME_WORDS = { GENERIC_PROJECT_NAME_WORDS = {
'app', 'application', 'harness', 'platform', 'project', 'purpose', 'service', 'solution', 'suite', 'system', 'test', 'tool', 'app', 'application', 'harness', 'platform', 'project', 'purpose', 'service', 'solution', 'suite', 'system', 'test', 'tool',
} }
PLACEHOLDER_PROJECT_NAME_WORDS = {
'generated project', 'new project', 'project', 'temporary name', 'temp name', 'placeholder', 'untitled project',
}
def __init__(self, ollama_url: str | None = None, model: str | None = None): def __init__(self, ollama_url: str | None = None, model: str | None = None):
self.ollama_url = (ollama_url or settings.ollama_url).rstrip('/') self.ollama_url = (ollama_url or settings.ollama_url).rstrip('/')
@@ -91,10 +88,15 @@ class RequestInterpreter:
}, },
expect_json=True, expect_json=True,
) )
if content: if not content:
raise RuntimeError('LLM request interpretation did not return a usable response.')
try: try:
parsed = json.loads(content) parsed = json.loads(content)
interpreted = self._normalize_interpreted_request(parsed, normalized) except Exception as exc:
raise RuntimeError('LLM request interpretation did not return valid JSON.') from exc
interpreted = self._normalize_interpreted_request(parsed)
routing = self._normalize_routing(parsed.get('routing'), interpreted, compact_context) routing = self._normalize_routing(parsed.get('routing'), interpreted, compact_context)
if routing.get('intent') == 'continue_project' and routing.get('project_name'): if routing.get('intent') == 'continue_project' and routing.get('project_name'):
interpreted['name'] = routing['project_name'] interpreted['name'] = routing['project_name']
@@ -111,27 +113,6 @@ class RequestInterpreter:
if naming_trace is not None: if naming_trace is not None:
trace['project_naming'] = naming_trace trace['project_naming'] = naming_trace
return interpreted, trace return interpreted, trace
except Exception:
pass
interpreted, routing = self._heuristic_fallback(normalized, compact_context)
if routing.get('intent') == 'new_project':
constraints = await self._collect_project_identity_constraints(compact_context)
routing['repo_name'] = self._ensure_unique_repo_name(routing.get('repo_name') or interpreted.get('name') or 'project', constraints['repo_names'])
return interpreted, {
'stage': 'request_interpretation',
'provider': 'heuristic',
'model': self.model,
'system_prompt': system_prompt,
'user_prompt': user_prompt,
'assistant_response': json.dumps({'request': interpreted, 'routing': routing}),
'raw_response': {'fallback': 'heuristic', 'llm_trace': trace.get('raw_response') if isinstance(trace, dict) else None},
'routing': routing,
'context_excerpt': compact_context,
'guardrails': trace.get('guardrails') if isinstance(trace, dict) else [],
'tool_context': trace.get('tool_context') if isinstance(trace, dict) else [],
'fallback_used': True,
}
async def _refine_new_project_identity( async def _refine_new_project_identity(
self, self,
@@ -159,26 +140,20 @@ class RequestInterpreter:
}, },
expect_json=True, expect_json=True,
) )
if content: if not content:
raise RuntimeError('LLM project naming did not return a usable response.')
try: try:
fallback_name = self._preferred_project_name_fallback(prompt_text, interpreted.get('name'))
parsed = json.loads(content) parsed = json.loads(content)
project_name, repo_name = self._normalize_project_identity( except Exception as exc:
parsed, raise RuntimeError('LLM project naming did not return valid JSON.') from exc
fallback_name=fallback_name,
) project_name, repo_name = self._normalize_project_identity(parsed)
repo_name = self._ensure_unique_repo_name(repo_name, constraints['repo_names']) repo_name = self._ensure_unique_repo_name(repo_name, constraints['repo_names'])
interpreted['name'] = project_name interpreted['name'] = project_name
routing['project_name'] = project_name routing['project_name'] = project_name
routing['repo_name'] = repo_name routing['repo_name'] = repo_name
return interpreted, routing, trace return interpreted, routing, trace
except Exception:
pass
fallback_name = self._preferred_project_name_fallback(prompt_text, interpreted.get('name'))
routing['project_name'] = fallback_name
routing['repo_name'] = self._ensure_unique_repo_name(self._derive_repo_name(fallback_name), constraints['repo_names'])
return interpreted, routing, trace
async def _collect_project_identity_constraints(self, context: dict) -> dict[str, set[str]]: async def _collect_project_identity_constraints(self, context: dict) -> dict[str, set[str]]:
"""Collect reserved project names and repository slugs from tracked state and Gitea.""" """Collect reserved project names and repository slugs from tracked state and Gitea."""
@@ -207,17 +182,19 @@ class RequestInterpreter:
return set() return set()
return {str(repo.get('name')).strip() for repo in repos if repo.get('name')} return {str(repo.get('name')).strip() for repo in repos if repo.get('name')}
def _normalize_interpreted_request(self, interpreted: dict, original_prompt: str) -> dict: def _normalize_interpreted_request(self, interpreted: dict) -> 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 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) if not isinstance(request_payload, dict):
if isinstance(request_payload, dict): raise RuntimeError('LLM request interpretation did not include a request object.')
name = str(request_payload.get('name') or '').strip() or self._derive_name(original_prompt) name = str(request_payload.get('name') or '').strip()
description = str((request_payload or {}).get('description') or '').strip() or original_prompt[:255] description = str(request_payload.get('description') or '').strip()
features = self._normalize_list((request_payload or {}).get('features')) if not name:
tech_stack = self._normalize_list((request_payload or {}).get('tech_stack')) raise RuntimeError('LLM request interpretation did not provide a project name.')
if not features: if not description:
features = ['core workflow based on free-form request'] raise RuntimeError('LLM request interpretation did not provide a project description.')
features = self._normalize_list(request_payload.get('features'))
tech_stack = self._normalize_list(request_payload.get('tech_stack'))
return { return {
'name': name[:255], 'name': name[:255],
'description': description[:255], 'description': description[:255],
@@ -251,6 +228,9 @@ class RequestInterpreter:
def _normalize_routing(self, routing: dict | None, interpreted: dict, context: dict) -> dict: def _normalize_routing(self, routing: dict | None, interpreted: dict, context: dict) -> dict:
"""Normalize routing metadata returned by the LLM.""" """Normalize routing metadata returned by the LLM."""
routing = routing or {} routing = routing or {}
intent = str(routing.get('intent') or '').strip()
if intent not in {'new_project', 'continue_project'}:
raise RuntimeError('LLM request interpretation did not provide a valid routing intent.')
project_id = routing.get('project_id') project_id = routing.get('project_id')
project_name = routing.get('project_name') project_name = routing.get('project_name')
issue_number = routing.get('issue_number') issue_number = routing.get('issue_number')
@@ -259,6 +239,7 @@ class RequestInterpreter:
elif isinstance(issue_number, str) and issue_number.isdigit(): elif isinstance(issue_number, str) and issue_number.isdigit():
issue_number = int(issue_number) issue_number = int(issue_number)
matched_project = None matched_project = None
if intent == 'continue_project':
for project in context.get('projects', []): for project in context.get('projects', []):
if project_id and project.get('project_id') == project_id: if project_id and project.get('project_id') == project_id:
matched_project = project matched_project = project
@@ -266,26 +247,24 @@ class RequestInterpreter:
if project_name and project.get('name') == project_name: if project_name and project.get('name') == project_name:
matched_project = project matched_project = project
break break
intent = str(routing.get('intent') or '').strip() or ('continue_project' if matched_project else 'new_project') elif project_id:
if matched_project is None and intent == 'continue_project':
recent_chat_history = context.get('recent_chat_history', [])
recent_project_id = recent_chat_history[0].get('project_id') if recent_chat_history else None
if recent_project_id:
matched_project = next( matched_project = next(
(project for project in context.get('projects', []) if project.get('project_id') == recent_project_id), (project for project in context.get('projects', []) if project.get('project_id') == project_id),
None, None,
) )
if intent == 'continue_project' and matched_project is None:
raise RuntimeError('LLM selected continue_project without identifying a tracked project from prompt history.')
if intent == 'new_project' and matched_project is not None:
raise RuntimeError('LLM selected new_project while also pointing at an existing tracked project.')
normalized = { normalized = {
'intent': intent, 'intent': intent,
'project_id': matched_project.get('project_id') if matched_project else project_id, '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')), 'project_name': matched_project.get('name') if matched_project else (project_name or interpreted.get('name')),
'repo_name': routing.get('repo_name') if intent == 'new_project' else None, 'repo_name': str(routing.get('repo_name') or '').strip() or None if intent == 'new_project' else None,
'issue_number': issue_number, 'issue_number': issue_number,
'confidence': routing.get('confidence') or ('medium' if matched_project else 'low'), 'confidence': routing.get('confidence') or 'medium',
'reasoning_summary': routing.get('reasoning_summary') or ('Matched prior project context' if matched_project else 'No strong prior project match found'), 'reasoning_summary': routing.get('reasoning_summary') or '',
} }
if normalized['intent'] == 'new_project' and not normalized['repo_name']:
normalized['repo_name'] = self._derive_repo_name(normalized['project_name'] or interpreted.get('name') or 'Generated Project')
return normalized return normalized
def _normalize_list(self, value) -> list[str]: def _normalize_list(self, value) -> list[str]:
@@ -295,42 +274,6 @@ class RequestInterpreter:
return [item.strip() for item in value.split(',') if item.strip()] return [item.strip() for item in value.split(',') if item.strip()]
return [] return []
def _derive_name(self, prompt_text: str) -> str:
"""Derive a stable project name when the LLM does not provide one."""
first_line = prompt_text.splitlines()[0].strip()
quoted = re.search(r'["\']([^"\']{3,80})["\']', first_line)
if quoted:
return self._humanize_name(quoted.group(1))
noun_phrase = re.search(
r'(?:build|create|start|make|develop|generate|design|need|want)\s+'
r'(?:me\s+|us\s+|an?\s+|the\s+|new\s+|internal\s+|simple\s+|lightweight\s+|modern\s+|web\s+|mobile\s+)*'
r'([a-z0-9][a-z0-9\s-]{2,80}?(?:portal|dashboard|app|application|service|tool|system|platform|api|bot|assistant|website|site|workspace|tracker|manager|harness|runner|framework|suite|pipeline|lab))\b',
first_line,
flags=re.IGNORECASE,
)
if noun_phrase:
return self._humanize_name(noun_phrase.group(1))
focused_phrase = re.search(
r'(?:purpose\s+is\s+to\s+create\s+(?:an?\s+)?)'
r'([a-z0-9][a-z0-9\s-]{2,80}?(?:portal|dashboard|app|application|service|tool|system|platform|api|bot|assistant|website|site|workspace|tracker|manager|harness|runner|framework|suite|pipeline|lab))\b',
first_line,
flags=re.IGNORECASE,
)
if focused_phrase:
return self._humanize_name(focused_phrase.group(1))
cleaned = re.sub(r'[^A-Za-z0-9 ]+', ' ', first_line)
stopwords = {
'build', 'create', 'start', 'make', 'develop', 'generate', 'design', 'need', 'want', 'please', 'for', 'our', 'with', 'that', 'this',
'new', 'internal', 'simple', 'modern', 'web', 'mobile', 'app', 'application', 'tool', 'system',
}
tokens = [word for word in cleaned.split() if word and word.lower() not in stopwords]
if tokens:
return self._humanize_name(' '.join(tokens[:4]))
return 'Generated Project'
def _humanize_name(self, raw_name: str) -> str: def _humanize_name(self, raw_name: str) -> str:
"""Normalize a candidate project name into a readable title.""" """Normalize a candidate project name into a readable title."""
cleaned = re.sub(r'[^A-Za-z0-9\s-]+', ' ', raw_name).strip(' -') cleaned = re.sub(r'[^A-Za-z0-9\s-]+', ' ', raw_name).strip(' -')
@@ -407,15 +350,6 @@ class RequestInterpreter:
return False return False
return True return True
def _preferred_project_name_fallback(self, prompt_text: str, interpreted_name: str | None) -> str:
"""Pick the best fallback title when the earlier interpretation produced a placeholder."""
interpreted_clean = self._humanize_name(str(interpreted_name or '').strip()) if interpreted_name else ''
normalized_interpreted = interpreted_clean.lower()
if normalized_interpreted and normalized_interpreted not in self.PLACEHOLDER_PROJECT_NAME_WORDS:
if not (len(normalized_interpreted.split()) == 1 and normalized_interpreted in self.GENERIC_PROJECT_NAME_WORDS):
return interpreted_clean
return self._derive_name(prompt_text)
def _ensure_unique_repo_name(self, repo_name: str, reserved_names: set[str]) -> str: def _ensure_unique_repo_name(self, repo_name: str, reserved_names: set[str]) -> str:
"""Choose a repository slug that does not collide with tracked or remote repositories.""" """Choose a repository slug that does not collide with tracked or remote repositories."""
base_name = self._derive_repo_name(repo_name) base_name = self._derive_repo_name(repo_name)
@@ -426,76 +360,19 @@ class RequestInterpreter:
suffix += 1 suffix += 1
return f'{base_name}-{suffix}' return f'{base_name}-{suffix}'
def _normalize_project_identity(self, payload: dict, fallback_name: str) -> tuple[str, str]: def _normalize_project_identity(self, payload: dict) -> tuple[str, str]:
"""Normalize model-proposed project and repository naming.""" """Validate model-proposed project and repository naming."""
fallback_project_name = self._humanize_name(str(fallback_name or 'Generated Project'))
project_candidate = str(payload.get('project_name') or payload.get('name') or '').strip() project_candidate = str(payload.get('project_name') or payload.get('name') or '').strip()
project_name = fallback_project_name
if project_candidate and self._should_use_project_name_candidate(project_candidate, fallback_project_name):
project_name = self._humanize_name(project_candidate)
repo_candidate = str(payload.get('repo_name') or '').strip() repo_candidate = str(payload.get('repo_name') or '').strip()
repo_name = self._derive_repo_name(project_name) if not project_candidate:
if repo_candidate and self._should_use_repo_name_candidate(repo_candidate, project_name): raise RuntimeError('LLM project naming did not provide a project name.')
repo_name = self._derive_repo_name(repo_candidate) if not repo_candidate:
return project_name, repo_name raise RuntimeError('LLM project naming did not provide a repository slug.')
if not self._should_use_project_name_candidate(project_candidate, project_candidate):
def _heuristic_fallback(self, prompt_text: str, context: dict | None = None) -> tuple[dict, dict]: raise RuntimeError('LLM project naming returned an unusable project name.')
"""Fallback request extraction when Ollama is unavailable.""" if not self._should_use_repo_name_candidate(repo_candidate, project_candidate):
lowered = prompt_text.lower() raise RuntimeError('LLM project naming returned an unusable repository slug.')
tech_candidates = [ return self._humanize_name(project_candidate), self._derive_repo_name(repo_candidate)
'python', 'fastapi', 'django', 'flask', 'postgresql', 'sqlite', 'react', 'vue', 'nicegui', 'docker'
]
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']
interpreted = {
'name': self._derive_name(prompt_text),
'description': sentences[0][:255] if sentences else prompt_text[:255],
'features': features,
'tech_stack': tech_stack,
}
routing = self._heuristic_routing(prompt_text, context or {})
if routing.get('project_name'):
interpreted['name'] = routing['project_name']
return interpreted, routing
def _heuristic_routing(self, prompt_text: str, context: dict) -> dict:
"""Best-effort routing when the LLM is unavailable."""
lowered = prompt_text.lower()
explicit_new = any(token in lowered for token in ['new project', 'start a new project', 'create a new project', 'build a new app'])
referenced_issue = self._extract_issue_number(prompt_text)
recent_history = context.get('recent_chat_history', [])
projects = context.get('projects', [])
last_project_id = recent_history[0].get('project_id') if recent_history else None
last_issue = ((recent_history[0].get('related_issue') or {}).get('number') if recent_history else None)
matched_project = None
for project in projects:
name = (project.get('name') or '').lower()
repo = ((project.get('repository') or {}).get('name') or '').lower()
if name and name in lowered:
matched_project = project
break
if repo and repo in lowered:
matched_project = project
break
if matched_project is None and not explicit_new:
follow_up_tokens = ['also', 'continue', 'for this project', 'for that project', 'work on this', 'work on that', 'fix that', 'add this']
if any(token in lowered for token in follow_up_tokens) and last_project_id:
matched_project = next((project for project in projects if project.get('project_id') == last_project_id), None)
issue_number = referenced_issue
if issue_number is None and any(token in lowered for token in ['that issue', 'this issue', 'the issue']) and last_issue is not None:
issue_number = last_issue
intent = 'new_project' if explicit_new or matched_project is None else 'continue_project'
return {
'intent': intent,
'project_id': matched_project.get('project_id') if matched_project else None,
'project_name': matched_project.get('name') if matched_project else self._derive_name(prompt_text),
'repo_name': None if matched_project else self._derive_repo_name(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: def _extract_issue_number(self, prompt_text: str) -> int | None:
match = re.search(r'(?:#|issue\s+)(\d+)', prompt_text, flags=re.IGNORECASE) match = re.search(r'(?:#|issue\s+)(\d+)', prompt_text, flags=re.IGNORECASE)

View File

@@ -261,6 +261,21 @@ def _render_generation_diagnostics(ui_data: dict | None) -> None:
ui.label(f"Remote push error: {git_debug['remote_error']}").classes('factory-code') ui.label(f"Remote push error: {git_debug['remote_error']}").classes('factory-code')
if git_debug.get('error'): if git_debug.get('error'):
ui.label(f"Git error: {git_debug['error']}").classes('factory-code') ui.label(f"Git error: {git_debug['error']}").classes('factory-code')
pull_request_debug = git_debug.get('pull_request_debug') if isinstance(git_debug.get('pull_request_debug'), dict) else {}
if pull_request_debug:
ui.label('Pull request creation').style('font-weight: 700; color: #2f241d;')
if pull_request_debug.get('status'):
ui.label(str(pull_request_debug['status'])).classes('factory-chip')
if pull_request_debug.get('request'):
with ui.expansion('PR request payload').classes('w-full q-mt-sm'):
ui.label(json.dumps(pull_request_debug['request'], indent=2, sort_keys=True)).classes('factory-code')
if pull_request_debug.get('response'):
with ui.expansion('PR API response').classes('w-full q-mt-sm'):
ui.label(json.dumps(pull_request_debug['response'], indent=2, sort_keys=True)).classes('factory-code')
if pull_request_debug.get('resolved'):
resolved = pull_request_debug['resolved']
if resolved.get('pr_url'):
ui.link('Open pull request', resolved['pr_url'], new_tab=True).classes('factory-code')
def _render_timeline(events: list[dict]) -> None: def _render_timeline(events: list[dict]) -> None:

View File

@@ -187,7 +187,6 @@ async def _derive_project_id_for_request(
) -> tuple[str, dict | None]: ) -> tuple[str, dict | None]:
"""Derive a stable project id for a newly created project.""" """Derive a stable project id for a newly created project."""
reserved_ids = {str(project.get('project_id')).strip() for project in existing_projects if project.get('project_id')} reserved_ids = {str(project.get('project_id')).strip() for project in existing_projects if project.get('project_id')}
fallback_id = _ensure_unique_identifier((prompt_routing or {}).get('project_name') or request.name, reserved_ids)
user_prompt = ( user_prompt = (
f"Original user prompt:\n{prompt_text}\n\n" f"Original user prompt:\n{prompt_text}\n\n"
f"Structured request:\n{json.dumps({'name': request.name, 'description': request.description, 'features': request.features, 'tech_stack': request.tech_stack}, indent=2)}\n\n" f"Structured request:\n{json.dumps({'name': request.name, 'description': request.description, 'features': request.features, 'tech_stack': request.tech_stack}, indent=2)}\n\n"
@@ -202,14 +201,16 @@ async def _derive_project_id_for_request(
tool_context_input={'projects': existing_projects}, tool_context_input={'projects': existing_projects},
expect_json=True, expect_json=True,
) )
if content: if not content:
raise RuntimeError('LLM project id naming did not return a usable response.')
try: try:
parsed = json.loads(content) parsed = json.loads(content)
candidate = parsed.get('project_id') or parsed.get('slug') or request.name except Exception as exc:
return _ensure_unique_identifier(str(candidate), reserved_ids), trace raise RuntimeError('LLM project id naming did not return valid JSON.') from exc
except Exception: candidate = str(parsed.get('project_id') or parsed.get('slug') or '').strip()
pass if not candidate:
return fallback_id, trace raise RuntimeError('LLM project id naming did not provide a project id.')
return _ensure_unique_identifier(candidate, reserved_ids), trace
def _serialize_project(history: ProjectHistory) -> dict: def _serialize_project(history: ProjectHistory) -> dict:
@@ -241,6 +242,17 @@ def _serialize_project_log(log: ProjectLog) -> dict:
} }
def _ensure_summary_mentions_pull_request(summary_message: str, pull_request: dict | None) -> str:
"""Append the pull request URL to chat summaries when one exists."""
if not isinstance(pull_request, dict):
return summary_message
pr_url = (pull_request.get('pr_url') or '').strip()
if not pr_url or pr_url in summary_message:
return summary_message
separator = '' if summary_message.endswith(('.', '!', '?')) else '.'
return f"{summary_message}{separator} Review PR: {pr_url}"
def _serialize_system_log(log: SystemLog) -> dict: def _serialize_system_log(log: SystemLog) -> dict:
"""Serialize a system log row.""" """Serialize a system log row."""
return { return {
@@ -391,6 +403,7 @@ async def _run_generation(
'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)],
} }
summary_message, summary_trace = await ChangeSummaryGenerator().summarize_with_trace(summary_context) summary_message, summary_trace = await ChangeSummaryGenerator().summarize_with_trace(summary_context)
summary_message = _ensure_summary_mentions_pull_request(summary_message, response_data.get('pull_request'))
if orchestrator.db_manager and orchestrator.history and orchestrator.prompt_audit: if orchestrator.db_manager and orchestrator.history and orchestrator.prompt_audit:
orchestrator.db_manager.log_llm_trace( orchestrator.db_manager.log_llm_trace(
project_id=project_id, project_id=project_id,