""" Projekt-Verwaltung — Stefans Idee fuer „Threads im Hauptchat verankert". Ein Projekt ist ein benanntes Thema-Bündel. Zwei Modi: - Hauptthread (kein aktives Projekt): klassischer rollender Chat. - In-Projekt: alle neuen Turns werden mit project_id getaggt. Die App zeigt sie als zusammenhängenden Block, einklappbar. Voice-Pattern (vom LLM via Meta-Tools getriggert): - „neues Projekt 'Aria-Wakeword'" → project_create - „steig in Projekt Spotify-Setup ein" → project_enter (Fuzzy-Match) - „Projekt Ende" → project_exit (zurueck zu Hauptthread) - „welche Projekte gibt's?" → project_list - „hol mich ab — was war zuletzt bei Projekt X?" → project_summary Persistenz: JSON-Liste in /shared/config/projects.json + aktive ID in /shared/config/active_project.txt. Single-User, single-active — keine Concurrency-Probleme. """ from __future__ import annotations import json import logging import os import re import time import uuid from difflib import SequenceMatcher from pathlib import Path from typing import Optional logger = logging.getLogger(__name__) PROJECTS_DIR = Path(os.environ.get("PROJECTS_DIR", "/shared/config")) PROJECTS_FILE = PROJECTS_DIR / "projects.json" ACTIVE_PROJECT_FILE = PROJECTS_DIR / "active_project.txt" def _now() -> int: return int(time.time()) def _load_all() -> list[dict]: if not PROJECTS_FILE.exists(): return [] try: data = json.loads(PROJECTS_FILE.read_text(encoding="utf-8")) return data if isinstance(data, list) else [] except Exception as exc: logger.warning("[projects] load failed: %s", exc) return [] def _save_all(projects: list[dict]) -> None: PROJECTS_DIR.mkdir(parents=True, exist_ok=True) PROJECTS_FILE.write_text( json.dumps(projects, indent=2, ensure_ascii=False), encoding="utf-8") def _slug(name: str) -> str: """Stabile ID aus Namen — fuer Voice-Matches. Lowercase, only a-z 0-9 _.""" s = name.strip().lower() s = re.sub(r"[^a-z0-9]+", "_", s) s = s.strip("_") return s or f"project_{_now()}" def list_projects(include_archived: bool = False) -> list[dict]: projects = _load_all() if not include_archived: projects = [p for p in projects if p.get("status") != "archived"] projects.sort(key=lambda p: p.get("last_activity_at", 0), reverse=True) return projects def get_project(project_id: str) -> Optional[dict]: if not project_id: return None for p in _load_all(): if p.get("id") == project_id: return p return None def find_project(query: str) -> Optional[dict]: """Fuzzy-Match auf Projekt-Namen — fuer Voice-Commands. Trifft auf: exact slug, prefix, substring, oder hoechste similarity > 0.6.""" q = (query or "").strip().lower() if not q: return None projects = _load_all() # 1. Exact ID-Match for p in projects: if p.get("id") == q: return p # 2. Exact / Prefix / Substring auf Slug + Name q_slug = _slug(q) for p in projects: if p.get("id") == q_slug: return p name_low = (p.get("name", "")).lower() if name_low == q or name_low.startswith(q) or q in name_low: return p # 3. Fuzzy best, best_score = None, 0.0 for p in projects: s = SequenceMatcher(None, q, p.get("name", "").lower()).ratio() if s > best_score: best, best_score = p, s if best and best_score >= 0.6: return best return None def create_project(name: str, description: str = "") -> dict: name = (name or "").strip() if not name: raise ValueError("Projektname darf nicht leer sein") base_id = _slug(name) projects = _load_all() # Dedup by id with suffix used_ids = {p["id"] for p in projects} pid = base_id counter = 2 while pid in used_ids: pid = f"{base_id}_{counter}" counter += 1 now = _now() project = { "id": pid, "name": name, "description": description.strip(), "status": "active", # active | ended | archived "created_at": now, "updated_at": now, "last_activity_at": now, "turn_count": 0, } projects.append(project) _save_all(projects) set_active(pid) logger.info("[projects] created %r (id=%s)", name, pid) return project def update_project(project_id: str, patch: dict) -> Optional[dict]: projects = _load_all() for p in projects: if p["id"] == project_id: for k in ("name", "description", "status"): if k in patch and patch[k] is not None: p[k] = patch[k] p["updated_at"] = _now() _save_all(projects) return p return None def archive_project(project_id: str) -> bool: if update_project(project_id, {"status": "archived"}) is not None: if get_active() == project_id: set_active("") return True return False def end_project(project_id: str) -> bool: """Markiert als beendet, aktive-Projekt-Pointer raus.""" if update_project(project_id, {"status": "ended"}) is not None: if get_active() == project_id: set_active("") return True return False def touch_project(project_id: str) -> None: """Bei jedem Turn im Projekt: last_activity + turn_count erhoehen.""" if not project_id: return projects = _load_all() changed = False for p in projects: if p["id"] == project_id: p["last_activity_at"] = _now() p["turn_count"] = int(p.get("turn_count", 0)) + 1 changed = True break if changed: _save_all(projects) # ── Active-Project-Pointer ───────────────────────────────────────── def get_active() -> str: """Returns die aktive Projekt-ID oder leer (= Hauptthread).""" try: if ACTIVE_PROJECT_FILE.exists(): return ACTIVE_PROJECT_FILE.read_text(encoding="utf-8").strip() except Exception: pass return "" def set_active(project_id: str) -> None: PROJECTS_DIR.mkdir(parents=True, exist_ok=True) ACTIVE_PROJECT_FILE.write_text(project_id or "", encoding="utf-8") logger.info("[projects] active project: %r", project_id or "(main)") def status() -> dict: """Status-Snapshot fuer App/Diagnostic.""" active_id = get_active() active = get_project(active_id) if active_id else None return { "active_id": active_id, "active": active, "projects": list_projects(include_archived=False), }