Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e40338bbf | |||
| 39f9651236 |
12
HISTORY.md
12
HISTORY.md
@@ -5,10 +5,22 @@ Changelog
|
|||||||
(unreleased)
|
(unreleased)
|
||||||
------------
|
------------
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- UI improvements and prompt hardening, refs NOISSUE. [Simon
|
||||||
|
Diesenreiter]
|
||||||
|
|
||||||
|
|
||||||
|
0.9.1 (2026-04-11)
|
||||||
|
------------------
|
||||||
|
|
||||||
Fix
|
Fix
|
||||||
~~~
|
~~~
|
||||||
- Better repo name generation, refs NOISSUE. [Simon Diesenreiter]
|
- Better repo name generation, refs NOISSUE. [Simon Diesenreiter]
|
||||||
|
|
||||||
|
Other
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
|
||||||
0.9.0 (2026-04-11)
|
0.9.0 (2026-04-11)
|
||||||
------------------
|
------------------
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.9.1
|
0.9.2
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ class RequestInterpreter:
|
|||||||
}
|
}
|
||||||
|
|
||||||
REPO_NOISE_WORDS = REQUEST_PREFIX_WORDS | {'and', 'from', 'into', 'on', 'that', 'this', 'to'}
|
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):
|
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('/')
|
||||||
@@ -153,10 +159,11 @@ class RequestInterpreter:
|
|||||||
)
|
)
|
||||||
if content:
|
if content:
|
||||||
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(
|
project_name, repo_name = self._normalize_project_identity(
|
||||||
parsed,
|
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'])
|
repo_name = self._ensure_unique_repo_name(repo_name, constraints['repo_names'])
|
||||||
interpreted['name'] = project_name
|
interpreted['name'] = project_name
|
||||||
@@ -166,7 +173,7 @@ class RequestInterpreter:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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['project_name'] = fallback_name
|
||||||
routing['repo_name'] = self._ensure_unique_repo_name(self._derive_repo_name(fallback_name), constraints['repo_names'])
|
routing['repo_name'] = self._ensure_unique_repo_name(self._derive_repo_name(fallback_name), constraints['repo_names'])
|
||||||
return interpreted, routing, trace
|
return interpreted, routing, trace
|
||||||
@@ -288,13 +295,22 @@ class RequestInterpreter:
|
|||||||
noun_phrase = re.search(
|
noun_phrase = re.search(
|
||||||
r'(?:build|create|start|make|develop|generate|design|need|want)\s+'
|
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'(?: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,
|
first_line,
|
||||||
flags=re.IGNORECASE,
|
flags=re.IGNORECASE,
|
||||||
)
|
)
|
||||||
if noun_phrase:
|
if noun_phrase:
|
||||||
return self._humanize_name(noun_phrase.group(1))
|
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)
|
cleaned = re.sub(r'[^A-Za-z0-9 ]+', ' ', first_line)
|
||||||
stopwords = {
|
stopwords = {
|
||||||
'build', 'create', 'start', 'make', 'develop', 'generate', 'design', 'need', 'want', 'please', 'for', 'our', 'with', 'that', 'this',
|
'build', 'create', 'start', 'make', 'develop', 'generate', 'design', 'need', 'want', 'please', 'for', 'our', 'with', 'that', 'this',
|
||||||
@@ -360,6 +376,36 @@ class RequestInterpreter:
|
|||||||
return False
|
return False
|
||||||
return True
|
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:
|
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)
|
||||||
@@ -372,7 +418,11 @@ class RequestInterpreter:
|
|||||||
|
|
||||||
def _normalize_project_identity(self, payload: dict, fallback_name: str) -> tuple[str, str]:
|
def _normalize_project_identity(self, payload: dict, fallback_name: str) -> tuple[str, str]:
|
||||||
"""Normalize model-proposed project and repository naming."""
|
"""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_candidate = str(payload.get('repo_name') or '').strip()
|
||||||
repo_name = self._derive_repo_name(project_name)
|
repo_name = self._derive_repo_name(project_name)
|
||||||
if repo_candidate and self._should_use_repo_name_candidate(repo_candidate, project_name):
|
if repo_candidate and self._should_use_repo_name_candidate(repo_candidate, project_name):
|
||||||
|
|||||||
@@ -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."
|
"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 = (
|
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 = (
|
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 = (
|
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."
|
"For project ids: produce short stable slugs for newly created projects. Avoid collisions with known project ids and keep ids lowercase with hyphens."
|
||||||
|
|||||||
@@ -665,6 +665,24 @@ def _add_dashboard_styles() -> None:
|
|||||||
"""Register shared dashboard styles."""
|
"""Register shared dashboard styles."""
|
||||||
ui.add_head_html(
|
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>
|
<style>
|
||||||
body { background: radial-gradient(circle at top, #f4efe7 0%, #e9e1d4 38%, #d7cec1 100%); }
|
body { background: radial-gradient(circle at top, #f4efe7 0%, #e9e1d4 38%, #d7cec1 100%); }
|
||||||
.factory-shell { max-width: 1240px; margin: 0 auto; }
|
.factory-shell { max-width: 1240px; margin: 0 auto; }
|
||||||
@@ -867,6 +885,24 @@ def create_dashboard():
|
|||||||
repo_discovery_key = 'dashboard.repo_discovery'
|
repo_discovery_key = 'dashboard.repo_discovery'
|
||||||
repo_owner_key = 'dashboard.repo_owner'
|
repo_owner_key = 'dashboard.repo_owner'
|
||||||
repo_name_key = 'dashboard.repo_name'
|
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:
|
def _llm_prompt_draft_key(prompt_key: str) -> str:
|
||||||
return f'dashboard.llm_prompt_draft.{prompt_key}'
|
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')
|
ui.label('No project data available yet.').classes('factory-muted')
|
||||||
for project_bundle in projects:
|
for project_bundle in projects:
|
||||||
project = project_bundle['project']
|
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'):
|
with ui.row().classes('items-center gap-2 q-pa-md'):
|
||||||
ui.button(
|
ui.button(
|
||||||
'Archive',
|
'Archive',
|
||||||
@@ -1438,7 +1479,12 @@ def create_dashboard():
|
|||||||
ui.label('No archived projects yet.').classes('factory-muted')
|
ui.label('No archived projects yet.').classes('factory-muted')
|
||||||
for project_bundle in archived_projects:
|
for project_bundle in archived_projects:
|
||||||
project = project_bundle['project']
|
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'):
|
with ui.row().classes('items-center gap-2 q-pa-md'):
|
||||||
ui.button(
|
ui.button(
|
||||||
'Restore',
|
'Restore',
|
||||||
@@ -1645,7 +1691,12 @@ def create_dashboard():
|
|||||||
if projects:
|
if projects:
|
||||||
for project_bundle in projects:
|
for project_bundle in projects:
|
||||||
project = project_bundle['project']
|
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))
|
_render_timeline(_filter_timeline_events(project_bundle.get('timeline', []), branch_scope_filter))
|
||||||
else:
|
else:
|
||||||
ui.label('No project timelines recorded yet.').classes('factory-muted')
|
ui.label('No project timelines recorded yet.').classes('factory-muted')
|
||||||
@@ -1831,7 +1882,8 @@ def create_dashboard():
|
|||||||
_update_dashboard_state()
|
_update_dashboard_state()
|
||||||
panel_refreshers['metrics']()
|
panel_refreshers['metrics']()
|
||||||
active_tab = _selected_tab_name()
|
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]()
|
panel_refreshers[active_tab]()
|
||||||
|
|
||||||
def _refresh_all_dashboard_sections() -> None:
|
def _refresh_all_dashboard_sections() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user