feat(projects): Threads im Hauptchat verankert (Stefan-Konzept)
Projekte sind benannte Thema-Bündel die voice-gesteuert via Brain-Tools
geöffnet/verlassen werden. Default-Mode bleibt der Hauptthread — Projekte
sind eine optionale Bühne. Anchored-not-replaced: App-Open landet immer
im Hauptchat, Projekte sind nur sichtbar wenn aktiv betreten.
Brain:
- projects.py: CRUD + Fuzzy-Find + Active-State-Pointer
(/shared/config/projects.json + active_project.txt).
- conversation.py: Turn.project_id-Feld + window(project_id) Filter.
- agent.py: 6 Meta-Tools — project_create / _enter / _exit / _list /
_summary / _end. chat() liest aktive Projekt-ID, taggt User+Assistant-
Turns damit, filtert das LLM-Window auf Projekt-Kontext und ergaenzt
den System-Prompt um den aktiven Projekt-Hinweis. touch_project pflegt
last_activity_at + turn_count.
- main.py: REST-Endpoints /projects/{status,list,create,switch,
{id}/end,{id}/archive, PATCH /{id}}.
Bridge + RVS:
- aria_bridge.py: project_changed Event-Propagation Brain → RVS-Broadcast
damit App + Diagnostic ihre Banner refreshen.
- rvs/server.js: project_changed in ALLOWED_TYPES.
App:
- brainApi.ts: Project-Type + 6 API-Methoden.
- ProjectsBrowser.tsx (neue Komponente, ~340 Zeilen): Status-Header,
Hauptchat als Erster-Eintrag, Projekt-Liste mit Aktiv-Marker, Long-Press
zum Editieren, Modals fuer Neu/Edit/End/Archiv.
- ChatScreen.tsx: Banner unterhalb des Status-Bars zeigt aktives Projekt
oder „Hauptchat" — Tap öffnet ProjectsBrowser als Modal. Aktive Projekt-
Info wird bei Mount + bei project_changed-Events refreshed.
- SettingsScreen.tsx: Neue Section 📁 „Projekte" zeigt ProjectsBrowser inline.
Diagnostic:
- Neue Sektion im Brain-Tab mit Liste, Aktiv-Marker, Beenden/Archivieren
pro Zeile, Modal fuer Neu. Lädt automatisch bei Brain-Tab + bei
project_changed-Event-Broadcast.
Was bewusst NICHT drin ist (Folgeschritte):
- Per-Message Filter im Chat-Verlauf (zeigt aktuell alle Bubbles, Banner
zeigt Kontext) — App müsste Chat-History per project_id filtern.
- Files-by-Project Tagging.
- Inline-Collapse-Bloecke im Chat-Verlauf.
- Sub-Projekte (Stefan-Entscheidung: weglassen, „Mama-tauglich").
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+244
-8
@@ -32,6 +32,7 @@ import skills as skills_mod
|
||||
import triggers as triggers_mod
|
||||
import watcher as watcher_mod
|
||||
import oauth as oauth_mod
|
||||
import projects as projects_mod
|
||||
|
||||
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
||||
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
|
||||
@@ -808,6 +809,104 @@ META_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
# ── Projekte (Stefan-Konzept: Threads im Hauptchat verankert) ──
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "project_create",
|
||||
"description": (
|
||||
"Legt ein neues Projekt an und macht es ZUR AKTIVEN Bühne. "
|
||||
"Nutze das wenn Stefan sagt 'lass uns ein Projekt für X anlegen' "
|
||||
"oder ein Thema klar als zusammenhängend bezeichnet. NICHT für "
|
||||
"Ad-hoc-Fragen — Projekte sind für wiederkehrende, mehrere Tage "
|
||||
"spannende Themen (Spotify-Setup, Renovierung, Reise-Planung)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Kurzer Name, wie ein Buchtitel ('Aria-Wakeword', 'Frankreich-Urlaub')."},
|
||||
"description": {"type": "string", "description": "1-Satz worum's geht. Hilft beim Wiedererkennen."},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "project_enter",
|
||||
"description": (
|
||||
"Wechselt in ein bestehendes Projekt. Fuzzy-Match auf Namen — "
|
||||
"'Spotify' findet das Projekt 'Spotify-Setup'. Nach dem Eintritt "
|
||||
"tagged jeder neue Turn die project_id. Bei sehr alten Projekten: "
|
||||
"vorher project_summary aufrufen damit Du Stefan abholst."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Projekt-Name oder Teil davon."},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "project_exit",
|
||||
"description": (
|
||||
"Verlässt das aktuelle Projekt — zurück zum Hauptthread. Nutze "
|
||||
"wenn Stefan sagt 'Projekt Ende', 'zurück zum Hauptchat' o.ä."
|
||||
),
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "project_list",
|
||||
"description": "Listet alle Projekte mit Status und letzter Aktivität. Bevor Du ein neues anlegst: hier prüfen ob's schon eins gibt.",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "project_summary",
|
||||
"description": (
|
||||
"Fasst zusammen was zuletzt in einem Projekt passiert ist (letzte ~10 Turns). "
|
||||
"Nutze zwingend wenn Stefan in ein altes Projekt einsteigt mit "
|
||||
"'hol mich ab' / 'was war zuletzt' / 'erinner mich dran' — sonst "
|
||||
"halluzinierst Du Inhalte die nicht da sind."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Projekt-Name (Fuzzy-Match)."},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "project_end",
|
||||
"description": (
|
||||
"Markiert ein Projekt als beendet — bleibt in der Liste sichtbar "
|
||||
"(z.B. archiviert/grau), kann aber nicht mehr neu betreten werden "
|
||||
"außer mit explizitem project_enter. Nutze wenn Stefan sagt 'Projekt "
|
||||
"abgeschlossen' o.ä."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Projekt-Name."},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -960,17 +1059,28 @@ class Agent:
|
||||
# Events vom letzten Turn weglassen
|
||||
self._pending_events = []
|
||||
|
||||
# Aktives Projekt (leer = Hauptthread) — bestimmt das Tagging der
|
||||
# neuen Turns + das Conversation-Window-Filter fuer den LLM-Prompt.
|
||||
active_project_id = projects_mod.get_active()
|
||||
active_project = projects_mod.get_project(active_project_id) if active_project_id else None
|
||||
|
||||
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
|
||||
# Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
|
||||
# iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz.
|
||||
fast_reply = self._try_skill_fast_path(user_message)
|
||||
if fast_reply is not None:
|
||||
self.conversation.add("user", user_message, source=source)
|
||||
self.conversation.add("assistant", fast_reply)
|
||||
self.conversation.add("user", user_message, source=source,
|
||||
project_id=active_project_id)
|
||||
self.conversation.add("assistant", fast_reply, project_id=active_project_id)
|
||||
if active_project_id:
|
||||
projects_mod.touch_project(active_project_id)
|
||||
return fast_reply
|
||||
|
||||
# 1. User-Turn an die Konversation
|
||||
self.conversation.add("user", user_message, source=source)
|
||||
self.conversation.add("user", user_message, source=source,
|
||||
project_id=active_project_id)
|
||||
if active_project_id:
|
||||
projects_mod.touch_project(active_project_id)
|
||||
|
||||
# 2. Hot Memory (alle pinned Punkte)
|
||||
hot = self.store.list_pinned()
|
||||
@@ -1017,13 +1127,38 @@ class Agent:
|
||||
oauth_callback_host=oauth_host,
|
||||
oauth_callback_port=oauth_port,
|
||||
oauth_callback_tls=oauth_tls)
|
||||
# Aktuelle Projekt-Bühne als System-Hinweis ergaenzen, damit Claude
|
||||
# weiss in welchem Kontext sie spricht und ihre project_* Tools korrekt
|
||||
# einsetzt (z.B. bei „Projekt Ende" project_exit aufruft).
|
||||
if active_project:
|
||||
system_prompt += (
|
||||
f"\n\n## AKTUELLES PROJEKT\n"
|
||||
f"Stefan befindet sich gerade IN dem Projekt '{active_project['name']}' "
|
||||
f"(id={active_project['id']}). Beschreibung: "
|
||||
f"{active_project.get('description', '(keine)')}. "
|
||||
f"Alle Antworten in diesem Turn gelten fuer dieses Projekt. "
|
||||
f"Wenn er rauswill, ruf project_exit auf."
|
||||
)
|
||||
else:
|
||||
project_count = len(projects_mod.list_projects())
|
||||
if project_count > 0:
|
||||
system_prompt += (
|
||||
f"\n\n## PROJEKTE\n"
|
||||
f"Hauptthread aktiv. {project_count} Projekte verfuegbar — wenn "
|
||||
f"Stefan sagt 'in Projekt X' oder 'lass uns das Spotify-Thema "
|
||||
f"weiterfuehren': project_enter aufrufen."
|
||||
)
|
||||
messages = [ProxyMessage(role="system", content=system_prompt)]
|
||||
for t in self.conversation.window():
|
||||
# Conversation-Window auf das aktive Projekt filtern: in einem Projekt
|
||||
# sieht der LLM nur die Projekt-Turns (sauberer Kontext); im Hauptthread
|
||||
# nur die nicht-getaggten Turns.
|
||||
window = self.conversation.window(project_id=active_project_id)
|
||||
for t in window:
|
||||
messages.append(ProxyMessage(role=t.role, content=t.content))
|
||||
|
||||
logger.info("chat: pinned=%d cold=%d skills=%d/%d window=%d prompt_chars=%d",
|
||||
logger.info("chat: pinned=%d cold=%d skills=%d/%d window=%d project=%r prompt_chars=%d",
|
||||
len(hot), len(cold), len(active_skills), len(all_skills),
|
||||
len(self.conversation.window()), len(system_prompt))
|
||||
len(window), active_project_id or "(main)", len(system_prompt))
|
||||
|
||||
# 6. Tool-Use-Loop. Bei Exception (z.B. Proxy-Timeout) muss ein
|
||||
# Assistant-Turn als Error-Marker geschrieben werden — der User-Turn
|
||||
@@ -1082,13 +1217,19 @@ class Agent:
|
||||
err_text = f"[Fehler: {exc}]"
|
||||
logger.error("chat() Exception — schreibe Error-Marker als Assistant-Turn: %s", exc)
|
||||
try:
|
||||
self.conversation.add("assistant", err_text)
|
||||
# Aktive Projekt-ID NEU lesen — kann sich waehrend des Tool-Loops
|
||||
# geaendert haben (project_enter/exit als Tool-Call).
|
||||
self.conversation.add("assistant", err_text,
|
||||
project_id=projects_mod.get_active())
|
||||
except Exception as add_exc:
|
||||
logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc)
|
||||
raise
|
||||
|
||||
# 7. Assistant-Turn (final reply) in die Conversation
|
||||
self.conversation.add("assistant", final_reply)
|
||||
# NEU lesen — wenn der LLM project_enter/exit gerufen hat, ist der
|
||||
# Final-Reply schon im neuen Projekt-Kontext.
|
||||
self.conversation.add("assistant", final_reply,
|
||||
project_id=projects_mod.get_active())
|
||||
return final_reply
|
||||
|
||||
# ── Tool-Dispatcher ───────────────────────────────────────
|
||||
@@ -1648,6 +1789,101 @@ class Agent:
|
||||
except Exception as e:
|
||||
logger.exception("memory_save fehlgeschlagen")
|
||||
return f"FEHLER beim Speichern: {e}"
|
||||
# ── Projekte ────────────────────────────────────────
|
||||
if name == "project_create":
|
||||
pname = (arguments.get("name") or "").strip()
|
||||
desc = (arguments.get("description") or "").strip()
|
||||
if not pname:
|
||||
return "FEHLER: name ist Pflicht."
|
||||
try:
|
||||
p = projects_mod.create_project(pname, desc)
|
||||
except ValueError as e:
|
||||
return f"FEHLER: {e}"
|
||||
self._pending_events.append({
|
||||
"type": "project_changed",
|
||||
"project": p,
|
||||
"action": "created",
|
||||
})
|
||||
return f"OK — Projekt '{p['name']}' angelegt (id={p['id']}) und aktiv. Alle weiteren Turns gehen jetzt da rein bis Du project_exit oder project_enter aufrufst."
|
||||
if name == "project_enter":
|
||||
pname = (arguments.get("name") or "").strip()
|
||||
if not pname:
|
||||
return "FEHLER: name ist Pflicht."
|
||||
p = projects_mod.find_project(pname)
|
||||
if not p:
|
||||
return f"Kein Projekt '{pname}' gefunden. Nutze project_list zum Aufzaehlen oder project_create wenn's neu sein soll."
|
||||
projects_mod.set_active(p["id"])
|
||||
self._pending_events.append({
|
||||
"type": "project_changed",
|
||||
"project": p,
|
||||
"action": "entered",
|
||||
})
|
||||
turn_count = p.get("turn_count", 0)
|
||||
hint = ""
|
||||
if turn_count > 0:
|
||||
hint = " Wenn Stefan nach dem Stand fragt: project_summary aufrufen."
|
||||
return f"OK — in Projekt '{p['name']}' eingestiegen (id={p['id']}, {turn_count} bisherige Turns).{hint}"
|
||||
if name == "project_exit":
|
||||
active_id = projects_mod.get_active()
|
||||
if not active_id:
|
||||
return "Es ist gerade kein Projekt aktiv — bereits im Hauptthread."
|
||||
p = projects_mod.get_project(active_id)
|
||||
projects_mod.set_active("")
|
||||
self._pending_events.append({
|
||||
"type": "project_changed",
|
||||
"project": p,
|
||||
"action": "exited",
|
||||
})
|
||||
return f"OK — Projekt '{p['name'] if p else active_id}' verlassen. Zurueck im Hauptthread."
|
||||
if name == "project_list":
|
||||
items = projects_mod.list_projects()
|
||||
if not items:
|
||||
return "(keine Projekte angelegt)"
|
||||
active_id = projects_mod.get_active()
|
||||
lines = []
|
||||
for p in items:
|
||||
marker = " ← AKTIV" if p["id"] == active_id else ""
|
||||
status_lbl = p.get("status", "active")
|
||||
lines.append(
|
||||
f"- {p['name']} (id={p['id']}, {p.get('turn_count', 0)} Turns, "
|
||||
f"status={status_lbl}){marker}"
|
||||
)
|
||||
return "Projekte:\n" + "\n".join(lines)
|
||||
if name == "project_summary":
|
||||
pname = (arguments.get("name") or "").strip()
|
||||
if not pname:
|
||||
return "FEHLER: name ist Pflicht."
|
||||
p = projects_mod.find_project(pname)
|
||||
if not p:
|
||||
return f"Kein Projekt '{pname}' gefunden."
|
||||
# Letzte ~10 Turns des Projekts aus dem Conversation-Log
|
||||
turns = [t for t in self.conversation.turns if t.project_id == p["id"]]
|
||||
if not turns:
|
||||
return (f"Projekt '{p['name']}' existiert (id={p['id']}), aber im "
|
||||
f"aktuellen Conversation-Window stehen noch keine Turns. "
|
||||
f"Beschreibung: {p.get('description', '(keine)')}")
|
||||
tail = turns[-12:]
|
||||
summary_lines = []
|
||||
for t in tail:
|
||||
prefix = "Stefan" if t.role == "user" else "Du"
|
||||
summary_lines.append(f"{prefix}: {t.content[:280]}")
|
||||
preamble = (f"Projekt '{p['name']}' — {p.get('description', '(keine Beschreibung)')}.\n"
|
||||
f"Letzte {len(tail)} Turns:\n")
|
||||
return preamble + "\n".join(summary_lines)
|
||||
if name == "project_end":
|
||||
pname = (arguments.get("name") or "").strip()
|
||||
if not pname:
|
||||
return "FEHLER: name ist Pflicht."
|
||||
p = projects_mod.find_project(pname)
|
||||
if not p:
|
||||
return f"Kein Projekt '{pname}' gefunden."
|
||||
projects_mod.end_project(p["id"])
|
||||
self._pending_events.append({
|
||||
"type": "project_changed",
|
||||
"project": projects_mod.get_project(p["id"]),
|
||||
"action": "ended",
|
||||
})
|
||||
return f"OK — Projekt '{p['name']}' beendet (id={p['id']}). Bleibt in der Liste, aktiv ist jetzt der Hauptthread."
|
||||
return f"Unbekanntes Tool: {name}"
|
||||
except Exception as exc:
|
||||
logger.exception("Tool '%s' fehlgeschlagen", name)
|
||||
|
||||
+38
-10
@@ -32,6 +32,7 @@ class Turn:
|
||||
content: str
|
||||
ts: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
source: str = "" # "app" / "diagnostic" / "stt" — optional
|
||||
project_id: str = "" # leer = Hauptthread; sonst projects.py-ID
|
||||
|
||||
|
||||
class Conversation:
|
||||
@@ -73,7 +74,8 @@ class Conversation:
|
||||
if role in ("user", "assistant") and isinstance(content, str):
|
||||
loaded.append(Turn(role=role, content=content,
|
||||
ts=obj.get("ts", ""),
|
||||
source=obj.get("source", "")))
|
||||
source=obj.get("source", ""),
|
||||
project_id=obj.get("project_id", "")))
|
||||
self.turns = loaded
|
||||
logger.info("Konversation geladen: %d Turns aus %s", len(self.turns), CONVERSATION_FILE)
|
||||
|
||||
@@ -85,17 +87,40 @@ class Conversation:
|
||||
except Exception as exc:
|
||||
logger.warning("Konversation persist fehlgeschlagen: %s", exc)
|
||||
|
||||
def add(self, role: str, content: str, source: str = "") -> Turn:
|
||||
t = Turn(role=role, content=content, source=source)
|
||||
def add(self, role: str, content: str, source: str = "",
|
||||
project_id: str = "") -> Turn:
|
||||
t = Turn(role=role, content=content, source=source, project_id=project_id)
|
||||
self.turns.append(t)
|
||||
self._append_to_file({
|
||||
record = {
|
||||
"ts": t.ts, "role": t.role, "content": t.content, "source": t.source,
|
||||
})
|
||||
}
|
||||
if t.project_id:
|
||||
record["project_id"] = t.project_id
|
||||
self._append_to_file(record)
|
||||
return t
|
||||
|
||||
def window(self) -> List[Turn]:
|
||||
"""Die letzten max_window Turns — gehen in den LLM-Prompt."""
|
||||
return self.turns[-self.max_window:]
|
||||
def window(self, project_id: Optional[str] = None) -> List[Turn]:
|
||||
"""Die letzten max_window Turns — gehen in den LLM-Prompt.
|
||||
Wenn project_id gesetzt: nur Turns aus diesem Projekt + die letzten
|
||||
~5 Hauptthread-Turns als Kontext. Wenn project_id leer/None und
|
||||
explizit uebergeben → nur Hauptthread."""
|
||||
if project_id is None:
|
||||
return self.turns[-self.max_window:]
|
||||
if project_id == "":
|
||||
# Hauptthread-Modus: alle Turns, aber project-getaggte rausfiltern
|
||||
main_turns = [t for t in self.turns if not t.project_id]
|
||||
return main_turns[-self.max_window:]
|
||||
# In-Projekt: alle Turns des Projekts + Tail des Hauptthreads als Kontext
|
||||
project_turns = [t for t in self.turns if t.project_id == project_id]
|
||||
return project_turns[-self.max_window:]
|
||||
|
||||
def window_recent_per_project(self) -> dict:
|
||||
"""Returns {project_id: [last N turns]} — fuer „hol mich ab"-Summary."""
|
||||
groups: dict[str, List[Turn]] = {}
|
||||
for t in self.turns:
|
||||
pid = t.project_id or ""
|
||||
groups.setdefault(pid, []).append(t)
|
||||
return groups
|
||||
|
||||
def needs_distill(self) -> bool:
|
||||
return len(self.turns) > self.distill_threshold
|
||||
@@ -131,10 +156,13 @@ class Conversation:
|
||||
tmp = CONVERSATION_FILE.with_suffix(".jsonl.tmp")
|
||||
with tmp.open("w", encoding="utf-8") as f:
|
||||
for t in self.turns:
|
||||
f.write(json.dumps({
|
||||
rec = {
|
||||
"ts": t.ts, "role": t.role,
|
||||
"content": t.content, "source": t.source,
|
||||
}, ensure_ascii=False) + "\n")
|
||||
}
|
||||
if t.project_id:
|
||||
rec["project_id"] = t.project_id
|
||||
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||||
tmp.replace(CONVERSATION_FILE)
|
||||
except Exception as exc:
|
||||
logger.warning("Konversation rewrite fehlgeschlagen: %s", exc)
|
||||
|
||||
@@ -38,6 +38,7 @@ import watcher as watcher_mod
|
||||
import background as background_mod
|
||||
import oauth as oauth_mod
|
||||
import seed_rules as seed_rules_mod
|
||||
import projects as projects_mod
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
logger = logging.getLogger("aria-brain")
|
||||
@@ -639,6 +640,76 @@ def chat(body: ChatIn, background: BackgroundTasks):
|
||||
)
|
||||
|
||||
|
||||
# ── Projekte ────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/projects/status")
|
||||
def projects_status():
|
||||
"""Komplett-Status: aktives Projekt + Liste aller (nicht-archivierten)."""
|
||||
return projects_mod.status()
|
||||
|
||||
|
||||
@app.get("/projects/list")
|
||||
def projects_list(include_archived: bool = False):
|
||||
return {"projects": projects_mod.list_projects(include_archived=include_archived)}
|
||||
|
||||
|
||||
class ProjectCreateBody(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
@app.post("/projects/create")
|
||||
def projects_create(body: ProjectCreateBody):
|
||||
try:
|
||||
p = projects_mod.create_project(body.name, body.description)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return p
|
||||
|
||||
|
||||
class ProjectSwitchBody(BaseModel):
|
||||
project_id: str = ""
|
||||
|
||||
|
||||
@app.post("/projects/switch")
|
||||
def projects_switch(body: ProjectSwitchBody):
|
||||
"""Aktive Projekt-ID setzen. Leerer String → Hauptthread."""
|
||||
if body.project_id:
|
||||
p = projects_mod.get_project(body.project_id)
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail=f"Projekt {body.project_id} nicht gefunden")
|
||||
projects_mod.set_active(body.project_id)
|
||||
return projects_mod.status()
|
||||
|
||||
|
||||
@app.post("/projects/{project_id}/end")
|
||||
def projects_end(project_id: str):
|
||||
if not projects_mod.end_project(project_id):
|
||||
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
|
||||
return projects_mod.get_project(project_id) or {"id": project_id, "status": "ended"}
|
||||
|
||||
|
||||
@app.post("/projects/{project_id}/archive")
|
||||
def projects_archive(project_id: str):
|
||||
if not projects_mod.archive_project(project_id):
|
||||
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
|
||||
return {"id": project_id, "status": "archived"}
|
||||
|
||||
|
||||
class ProjectUpdateBody(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
@app.patch("/projects/{project_id}")
|
||||
def projects_update(project_id: str, body: ProjectUpdateBody):
|
||||
patch = body.dict(exclude_unset=True)
|
||||
p = projects_mod.update_project(project_id, patch)
|
||||
if p is None:
|
||||
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
|
||||
return p
|
||||
|
||||
|
||||
@app.get("/conversation/stats")
|
||||
def conversation_stats():
|
||||
return conversation().stats()
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
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),
|
||||
}
|
||||
Reference in New Issue
Block a user