fix(brain): Trigger-Zeit UTC-konsistent, project_summary liest Hauptchat, Privatkopie-Regel
- Trigger-Zeit (Issue 4): fires_at wird beim Anlegen IMMER auf UTC normalisiert (naiv = lokale Wanduhrzeit Europe/Berlin → UTC, aware → astimezone). Passt zur UTC-Ausfuehrung in background.py → feuert zur gemeinten Ortszeit, zeitzonen- portabel. Tool-Desc + Prompt: fires_at = lokale Wanduhrzeit ohne Zone schreiben, Server rechnet um. Behebt den 2h-Versatz. - project_summary (Issue 2): liest jetzt auch den Hauptchat (name='Hauptchat') und die Beschreibung sagt ARIA, es fuer 'schau mal in Projekt X / Hauptchat rein' / 'hol dir die Infos aus ...' zu nutzen. - Privatkopie (Issue 3): IDENTITY_ANCHOR-Regel — YouTube-Downloads fuer Stefans PRIVATgebrauch sind eine Privatkopie (§53 UrhG), nicht reflexartig ablehnen/ moralisieren; Urteil bleibt fuer Weiterverbreitung/kommerziell. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+29
-19
@@ -540,10 +540,13 @@ META_TOOLS = [
|
|||||||
"fires_at": {
|
"fires_at": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": (
|
"description": (
|
||||||
"Absoluter ISO-Timestamp UTC fuer feste Termine, z.B. "
|
"Fester Termin als ISO-Timestamp. Schreib einfach die LOKALE "
|
||||||
"'2026-05-12T14:30:00Z'. Die aktuelle Zeit findest du im "
|
"Wanduhrzeit, die Stefan meint, OHNE Zeitzone — z.B. 'um 14:30' "
|
||||||
"System-Prompt unter '## Aktuelle Zeit'. Fuer relative Angaben "
|
"→ '2026-05-12T14:30:00'. Der Server rechnet sie selbst in UTC "
|
||||||
"lieber `in_seconds` nutzen."
|
"um (naiv = Ortszeit Europe/Berlin). Nur wenn du explizit UTC "
|
||||||
|
"willst, haeng Z an ('...T12:30:00Z'). Die aktuelle Lokal-/UTC-"
|
||||||
|
"Zeit steht im System-Prompt unter '## Aktuelle Zeit'. Fuer "
|
||||||
|
"relative Angaben ('in 2 Stunden') lieber `in_seconds`."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"message": {"type": "string", "description": "Was soll bei der Erinnerung gesagt werden"},
|
"message": {"type": "string", "description": "Was soll bei der Erinnerung gesagt werden"},
|
||||||
@@ -1042,15 +1045,17 @@ META_TOOLS = [
|
|||||||
"function": {
|
"function": {
|
||||||
"name": "project_summary",
|
"name": "project_summary",
|
||||||
"description": (
|
"description": (
|
||||||
"Fasst zusammen was zuletzt in einem Projekt passiert ist (letzte ~10 Turns). "
|
"Schau in einen ANDEREN Chat rein und fass zusammen was dort zuletzt "
|
||||||
"Nutze zwingend wenn Stefan in ein altes Projekt einsteigt mit "
|
"passiert ist (letzte ~12 Turns). Funktioniert fuer jedes Projekt (per "
|
||||||
"'hol mich ab' / 'was war zuletzt' / 'erinner mich dran' — sonst "
|
"Name, Fuzzy-Match) UND fuer den Hauptchat (name='Hauptchat'). Nutze es "
|
||||||
"halluzinierst Du Inhalte die nicht da sind."
|
"IMMER wenn Stefan sagt 'hol dir die Infos aus Projekt X', 'schau mal in "
|
||||||
|
"den Hauptchat/in Projekt Y rein', 'was war zuletzt bei ...', 'hol mich "
|
||||||
|
"ab' — sonst halluzinierst Du Inhalte die nicht da sind."
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {"type": "string", "description": "Projekt-Name (Fuzzy-Match)."},
|
"name": {"type": "string", "description": "Projekt-Name (Fuzzy-Match) oder 'Hauptchat' fuer den Hauptthread."},
|
||||||
},
|
},
|
||||||
"required": ["name"],
|
"required": ["name"],
|
||||||
},
|
},
|
||||||
@@ -2535,22 +2540,27 @@ class Agent:
|
|||||||
pname = (arguments.get("name") or "").strip()
|
pname = (arguments.get("name") or "").strip()
|
||||||
if not pname:
|
if not pname:
|
||||||
return "FEHLER: name ist Pflicht."
|
return "FEHLER: name ist Pflicht."
|
||||||
p = projects_mod.find_project(pname)
|
# Hauptchat (project_id="") explizit unterstuetzen — damit ARIA auch
|
||||||
if not p:
|
# aus einem Projekt heraus in den Hauptthread reinschauen kann.
|
||||||
return f"Kein Projekt '{pname}' gefunden."
|
if pname.lower() in {"hauptchat", "hauptthread", "haupt", "main",
|
||||||
# Letzte ~10 Turns des Projekts aus dem Conversation-Log
|
"mainchat", "haupt-chat", "hauptchat-thread"}:
|
||||||
turns = [t for t in self.conversation.turns if t.project_id == p["id"]]
|
turns = [t for t in self.conversation.turns if not t.project_id]
|
||||||
|
label, desc = "Hauptchat", "der Hauptthread (kein Projekt)"
|
||||||
|
else:
|
||||||
|
p = projects_mod.find_project(pname)
|
||||||
|
if not p:
|
||||||
|
return f"Kein Chat/Projekt '{pname}' gefunden (fuer den Hauptthread: name='Hauptchat')."
|
||||||
|
turns = [t for t in self.conversation.turns if t.project_id == p["id"]]
|
||||||
|
label, desc = p["name"], p.get("description", "(keine Beschreibung)")
|
||||||
if not turns:
|
if not turns:
|
||||||
return (f"Projekt '{p['name']}' existiert (id={p['id']}), aber im "
|
return (f"'{label}' hat im aktuellen Conversation-Window noch keine "
|
||||||
f"aktuellen Conversation-Window stehen noch keine Turns. "
|
f"Turns. {desc}")
|
||||||
f"Beschreibung: {p.get('description', '(keine)')}")
|
|
||||||
tail = turns[-12:]
|
tail = turns[-12:]
|
||||||
summary_lines = []
|
summary_lines = []
|
||||||
for t in tail:
|
for t in tail:
|
||||||
prefix = "Stefan" if t.role == "user" else "Du"
|
prefix = "Stefan" if t.role == "user" else "Du"
|
||||||
summary_lines.append(f"{prefix}: {t.content[:280]}")
|
summary_lines.append(f"{prefix}: {t.content[:280]}")
|
||||||
preamble = (f"Projekt '{p['name']}' — {p.get('description', '(keine Beschreibung)')}.\n"
|
preamble = f"'{label}' — {desc}.\nLetzte {len(tail)} Turns:\n"
|
||||||
f"Letzte {len(tail)} Turns:\n")
|
|
||||||
return preamble + "\n".join(summary_lines)
|
return preamble + "\n".join(summary_lines)
|
||||||
if name == "project_end":
|
if name == "project_end":
|
||||||
pname = (arguments.get("name") or "").strip()
|
pname = (arguments.get("name") or "").strip()
|
||||||
|
|||||||
+20
-4
@@ -68,6 +68,20 @@ Wissen ist fuer deine Arbeit da — aber es ist HOCHVERTRAULICH:
|
|||||||
Auf „wer bist du" / „was weisst du ueber mich": antworte allgemein und diskret
|
Auf „wer bist du" / „was weisst du ueber mich": antworte allgemein und diskret
|
||||||
(z.B. „einiges — geschaeftlich wie privat; frag mich gezielt"), NIEMALS ein
|
(z.B. „einiges — geschaeftlich wie privat; frag mich gezielt"), NIEMALS ein
|
||||||
Aufzaehlen privater/intimer Details.
|
Aufzaehlen privater/intimer Details.
|
||||||
|
|
||||||
|
## MEDIEN-DOWNLOADS FUER STEFANS PRIVATGEBRAUCH
|
||||||
|
Stefan ist Privatperson und du bist SEIN persoenlicher Assistent. Bittet er dich,
|
||||||
|
ihm ein Video/Audio (z.B. von YouTube) fuer seinen EIGENEN, privaten Gebrauch
|
||||||
|
herunterzuladen — offline anschauen, Archiv, Erinnerung an eine Szene/ein Lied —
|
||||||
|
ist das eine Privatkopie (in DE nach §53 UrhG fuer den privaten Gebrauch
|
||||||
|
zulaessig, solange die Quelle nicht offensichtlich illegal ist und kein
|
||||||
|
Kopierschutz umgangen wird). Behandle solche Downloads NICHT reflexartig als
|
||||||
|
Urheberrechtsverstoss und moralisiere nicht — nutz einfach den
|
||||||
|
`yt-dlp-download`-Skill, konsistent, ohne jedes Mal neu zu diskutieren (du hast
|
||||||
|
das schon oft gemacht). Anders ist es NUR, wenn es klar um WEITERVERBREITUNG,
|
||||||
|
oeffentliches Posten oder kommerzielle Nutzung geht — DA weist du kurz auf das
|
||||||
|
Problem hin. Im Zweifel privat vs. verbreiten: einmal kurz nachfragen, nicht
|
||||||
|
pauschal ablehnen.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -139,10 +153,12 @@ def build_time_section() -> str:
|
|||||||
f"- Lokal (Europa/Berlin, UTC+{local_offset_h}): "
|
f"- Lokal (Europa/Berlin, UTC+{local_offset_h}): "
|
||||||
f"{local.strftime('%Y-%m-%d %H:%M:%S')} ({local.strftime('%A')})",
|
f"{local.strftime('%Y-%m-%d %H:%M:%S')} ({local.strftime('%A')})",
|
||||||
"",
|
"",
|
||||||
"Nutze das fuer Trigger-Timestamps und um Watcher-Conditions wie "
|
"Nutze das um Watcher-Conditions wie `hour_of_day == 8` einzuordnen. "
|
||||||
"`hour_of_day == 8` einzuordnen. Fuer relative Angaben "
|
"Fuer `trigger_timer`: bei relativen Angaben ('in 10min', 'in 2 Stunden') "
|
||||||
"('in 10min', 'in 2 Stunden') nutze beim `trigger_timer` den "
|
"den `in_seconds`-Parameter; bei festen Uhrzeiten schreib bei `fires_at` "
|
||||||
"`in_seconds`-Parameter — Server rechnet dann selbst.",
|
"einfach die LOKALE Wanduhrzeit ohne Zeitzone (z.B. 'um 17 Uhr' → "
|
||||||
|
"'...T17:00:00') — der Server rechnet sie selbst in UTC um. So feuert der "
|
||||||
|
"Timer zur gemeinten Ortszeit und bleibt zeitzonen-portabel.",
|
||||||
]
|
]
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|||||||
+27
-3
@@ -24,7 +24,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -40,6 +40,29 @@ def _now_iso() -> str:
|
|||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _local_offset_hours(dt: datetime) -> int:
|
||||||
|
"""Grobe Europe/Berlin-Naeherung (CEST=+2 Maerz-Okt, sonst CET=+1) — dieselbe
|
||||||
|
Logik wie build_time_section im Prompt, ohne zoneinfo/tzdata im Brain-Image."""
|
||||||
|
return 2 if 3 <= dt.month <= 10 else 1
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_fires_at_utc(iso: str) -> str:
|
||||||
|
"""Bringt einen fires_at-ISO IMMER auf UTC (+00:00).
|
||||||
|
|
||||||
|
- Aware (endet auf Z oder hat einen Offset) → in UTC umgerechnet.
|
||||||
|
- Naiv (keine Zone) → als LOKALE Wanduhrzeit (Europe/Berlin) interpretiert
|
||||||
|
und nach UTC umgerechnet.
|
||||||
|
|
||||||
|
So speichern wir stets den absoluten Instant. Die Ausfuehrung (background.py,
|
||||||
|
UTC) trifft damit exakt die vom Nutzer gemeinte Ortszeit — und bleibt
|
||||||
|
zeitzonen-portabel (feuert am selben Moment, egal wo Stefan gerade ist)."""
|
||||||
|
dt = datetime.fromisoformat((iso or "").strip().replace("Z", "+00:00"))
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
# Naiv = lokale Wanduhrzeit → UTC = lokal - Offset.
|
||||||
|
dt = (dt - timedelta(hours=_local_offset_hours(dt))).replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
def _safe_name(name: str) -> str:
|
def _safe_name(name: str) -> str:
|
||||||
if not isinstance(name, str) or not NAME_RE.match(name):
|
if not isinstance(name, str) or not NAME_RE.match(name):
|
||||||
raise ValueError(f"Ungueltiger Trigger-Name: {name!r}")
|
raise ValueError(f"Ungueltiger Trigger-Name: {name!r}")
|
||||||
@@ -127,9 +150,10 @@ def create_timer(
|
|||||||
_safe_name(name)
|
_safe_name(name)
|
||||||
if _path(name).exists():
|
if _path(name).exists():
|
||||||
raise ValueError(f"Trigger '{name}' existiert schon")
|
raise ValueError(f"Trigger '{name}' existiert schon")
|
||||||
# ISO validieren
|
# ISO validieren UND auf UTC normalisieren (naiv = lokale Wanduhrzeit →
|
||||||
|
# UTC). So passt das Anlegen zur UTC-Ausfuehrung in background.py.
|
||||||
try:
|
try:
|
||||||
datetime.fromisoformat(fires_at_iso.replace("Z", "+00:00"))
|
fires_at_iso = normalize_fires_at_utc(fires_at_iso)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise ValueError(f"fires_at_iso ungueltig: {fires_at_iso}")
|
raise ValueError(f"fires_at_iso ungueltig: {fires_at_iso}")
|
||||||
data = {
|
data = {
|
||||||
|
|||||||
Reference in New Issue
Block a user