2 Commits
0.9.1 ... 0.9.2

Author SHA1 Message Date
3e40338bbf release: version 0.9.2 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 31s
Upload Python Package / deploy (push) Successful in 32s
2026-04-11 11:53:25 +02:00
39f9651236 fix: UI improvements and prompt hardening, refs NOISSUE 2026-04-11 11:53:18 +02:00
5 changed files with 125 additions and 11 deletions

View File

@@ -5,10 +5,22 @@ Changelog
(unreleased)
------------
Fix
~~~
- UI improvements and prompt hardening, refs NOISSUE. [Simon
Diesenreiter]
0.9.1 (2026-04-11)
------------------
Fix
~~~
- Better repo name generation, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.9.0 (2026-04-11)
------------------

View File

@@ -1 +1 @@
0.9.1
0.9.2

View File

@@ -25,6 +25,12 @@ class RequestInterpreter:
}
REPO_NOISE_WORDS = REQUEST_PREFIX_WORDS | {'and', 'from', 'into', 'on', 'that', 'this', 'to'}
GENERIC_PROJECT_NAME_WORDS = {
'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):
self.ollama_url = (ollama_url or settings.ollama_url).rstrip('/')
@@ -153,10 +159,11 @@ class RequestInterpreter:
)
if content:
try:
fallback_name = self._preferred_project_name_fallback(prompt_text, interpreted.get('name'))
parsed = json.loads(content)
project_name, repo_name = self._normalize_project_identity(
parsed,
fallback_name=interpreted.get('name') or self._derive_name(prompt_text),
fallback_name=fallback_name,
)
repo_name = self._ensure_unique_repo_name(repo_name, constraints['repo_names'])
interpreted['name'] = project_name
@@ -166,7 +173,7 @@ class RequestInterpreter:
except Exception:
pass
fallback_name = interpreted.get('name') or self._derive_name(prompt_text)
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
@@ -288,13 +295,22 @@ class RequestInterpreter:
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))\b',
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',
@@ -360,6 +376,36 @@ class RequestInterpreter:
return False
return True
def _should_use_project_name_candidate(self, candidate: str, fallback_name: str) -> bool:
"""Return whether a model-proposed project title is concrete enough to trust."""
cleaned = self._trim_request_prefix(re.sub(r'[^A-Za-z0-9\s-]+', ' ', candidate or '').strip())
if not cleaned:
return False
candidate_tokens = [token.lower() for token in re.split(r'[-\s]+', cleaned) if token]
if not candidate_tokens:
return False
if len(candidate_tokens) == 1 and candidate_tokens[0] in self.GENERIC_PROJECT_NAME_WORDS:
return False
if all(token in self.GENERIC_PROJECT_NAME_WORDS for token in candidate_tokens):
return False
fallback_tokens = {
token.lower() for token in re.split(r'[-\s]+', fallback_name or '') if token and token.lower() not in self.REPO_NOISE_WORDS
}
if fallback_tokens and len(candidate_tokens) <= 2:
overlap = sum(1 for token in candidate_tokens if token in fallback_tokens)
if overlap == 0 and any(token in self.GENERIC_PROJECT_NAME_WORDS for token in candidate_tokens):
return False
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:
"""Choose a repository slug that does not collide with tracked or remote repositories."""
base_name = self._derive_repo_name(repo_name)
@@ -372,7 +418,11 @@ class RequestInterpreter:
def _normalize_project_identity(self, payload: dict, fallback_name: str) -> tuple[str, str]:
"""Normalize model-proposed project and repository naming."""
project_name = self._humanize_name(str(payload.get('project_name') or payload.get('name') or fallback_name))
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_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_name = self._derive_repo_name(project_name)
if repo_candidate and self._should_use_repo_name_candidate(repo_candidate, project_name):

View File

@@ -120,10 +120,10 @@ class Settings(BaseSettings):
"For summaries: only describe facts present in the provided context and tool outputs. Never claim a repository, commit, or pull request exists unless it is present in the supplied data."
)
LLM_PROJECT_NAMING_GUARDRAIL_PROMPT: str = (
"For project naming: prefer clear, product-like names and repository slugs that match the user's intent. Avoid reusing tracked project identities unless the request is clearly asking for an existing project."
"For project naming: prefer clear, product-like names and repository slugs that match the user's concrete deliverable. Avoid abstract or instructional words such as purpose, project, system, app, tool, platform, solution, new, create, or test unless the request truly centers on that exact noun. Base the name on the actual artifact or workflow being built, and avoid copying sentence fragments from the prompt. Avoid reusing tracked project identities unless the request is clearly asking for an existing project."
)
LLM_PROJECT_NAMING_SYSTEM_PROMPT: str = (
"You name newly requested software projects. Return only JSON with keys project_name, repo_name, and rationale. Project names should be concise human-readable titles. Repo names should be lowercase kebab-case slugs suitable for a Gitea repository name."
"You name newly requested software projects. Return only JSON with keys project_name, repo_name, and rationale. Project names should be concise human-readable titles based on the real product, artifact, or workflow being created. Repo names should be lowercase kebab-case slugs derived from that title. Never return generic names like purpose, project, system, app, tool, platform, solution, harness, or test by themselves, and never return a repo_name that is a copied sentence fragment from the prompt. Prefer 2 to 4 specific words when possible."
)
LLM_PROJECT_ID_GUARDRAIL_PROMPT: str = (
"For project ids: produce short stable slugs for newly created projects. Avoid collisions with known project ids and keep ids lowercase with hyphens."

View File

@@ -665,6 +665,24 @@ def _add_dashboard_styles() -> None:
"""Register shared dashboard styles."""
ui.add_head_html(
"""
<script>
(() => {
const scrollKey = 'factory-dashboard-scroll-y';
const rememberScroll = () => sessionStorage.setItem(scrollKey, String(window.scrollY || 0));
const restoreScroll = () => {
const stored = sessionStorage.getItem(scrollKey);
if (stored === null) return;
window.requestAnimationFrame(() => window.scrollTo({top: Number(stored) || 0, left: 0, behavior: 'auto'}));
};
window.addEventListener('scroll', rememberScroll, {passive: true});
document.addEventListener('click', rememberScroll, true);
const observer = new MutationObserver(() => restoreScroll());
window.addEventListener('load', () => {
observer.observe(document.body, {childList: true, subtree: true});
restoreScroll();
});
})();
</script>
<style>
body { background: radial-gradient(circle at top, #f4efe7 0%, #e9e1d4 38%, #d7cec1 100%); }
.factory-shell { max-width: 1240px; margin: 0 auto; }
@@ -867,6 +885,24 @@ def create_dashboard():
repo_discovery_key = 'dashboard.repo_discovery'
repo_owner_key = 'dashboard.repo_owner'
repo_name_key = 'dashboard.repo_name'
expansion_state_prefix = 'dashboard.expansion.'
def _expansion_state_key(name: str) -> str:
return f'{expansion_state_prefix}{name}'
def _expansion_value(name: str, default: bool = False) -> bool:
return bool(app.storage.user.get(_expansion_state_key(name), default))
def _store_expansion_value(name: str, event) -> None:
app.storage.user[_expansion_state_key(name)] = bool(event.value)
def _sticky_expansion(name: str, text: str, *, icon: str | None = None, default: bool = False, classes: str = 'w-full'):
return ui.expansion(
text,
icon=icon,
value=_expansion_value(name, default),
on_value_change=lambda event, expansion_name=name: _store_expansion_value(expansion_name, event),
).classes(classes)
def _llm_prompt_draft_key(prompt_key: str) -> str:
return f'dashboard.llm_prompt_draft.{prompt_key}'
@@ -1393,7 +1429,12 @@ def create_dashboard():
ui.label('No project data available yet.').classes('factory-muted')
for project_bundle in projects:
project = project_bundle['project']
with ui.expansion(f"{project['project_name']} · {project['status']}", icon='folder').classes('factory-panel w-full q-mb-md'):
with _sticky_expansion(
f"projects.{project['project_id']}",
f"{project['project_name']} · {project['status']}",
icon='folder',
classes='factory-panel w-full q-mb-md',
):
with ui.row().classes('items-center gap-2 q-pa-md'):
ui.button(
'Archive',
@@ -1438,7 +1479,12 @@ def create_dashboard():
ui.label('No archived projects yet.').classes('factory-muted')
for project_bundle in archived_projects:
project = project_bundle['project']
with ui.expansion(f"{project['project_name']} · archived", icon='archive').classes('factory-panel w-full q-mb-md'):
with _sticky_expansion(
f"archived.{project['project_id']}",
f"{project['project_name']} · archived",
icon='archive',
classes='factory-panel w-full q-mb-md',
):
with ui.row().classes('items-center gap-2 q-pa-md'):
ui.button(
'Restore',
@@ -1645,7 +1691,12 @@ def create_dashboard():
if projects:
for project_bundle in projects:
project = project_bundle['project']
with ui.expansion(f"{project['project_name']} · {project['project_id']}", icon='schedule').classes('q-mt-md w-full'):
with _sticky_expansion(
f"timeline.{project['project_id']}",
f"{project['project_name']} · {project['project_id']}",
icon='schedule',
classes='q-mt-md w-full',
):
_render_timeline(_filter_timeline_events(project_bundle.get('timeline', []), branch_scope_filter))
else:
ui.label('No project timelines recorded yet.').classes('factory-muted')
@@ -1831,7 +1882,8 @@ def create_dashboard():
_update_dashboard_state()
panel_refreshers['metrics']()
active_tab = _selected_tab_name()
if active_tab in panel_refreshers:
# Avoid rebuilding the more interactive tabs on the timer; manual refresh keeps them current.
if active_tab in {'overview', 'health'} and active_tab in panel_refreshers:
panel_refreshers[active_tab]()
def _refresh_all_dashboard_sections() -> None: