chore: update publish tooling and Android messaging integration
This commit is contained in:
parent
87a8deb83c
commit
e3bcddac70
13 changed files with 696 additions and 176 deletions
9
.github/copilot-instructions.md
vendored
9
.github/copilot-instructions.md
vendored
|
|
@ -38,3 +38,12 @@
|
|||
- Der String `krisenvorrat` ist vollständig veraltet und darf **nirgendwo** verwendet werden: nicht in Pfaden, nicht in Namen, nicht in Kommentaren, nicht in Befehlen.
|
||||
- Korrekt: `/opt/bollwerk/`, `bollwerk-server`, `bollwerk.online`
|
||||
- Falsch: `/opt/krisenvorrat/`, `krisenvorrat`
|
||||
|
||||
---
|
||||
|
||||
## Versionierung (PFLICHT)
|
||||
|
||||
- `versionName` muss immer strikt dem Muster `x.y.z` folgen (z. B. `1.7.13`).
|
||||
- Das dritte Segment (`z`) muss dem `versionCode` entsprechen.
|
||||
- Formate wie `1.7 (build 13)` sind verboten.
|
||||
- Bei Release-Commit-Messages darf kein Klammer-`build`-Format verwendet werden.
|
||||
|
|
|
|||
9
.github/skills/publish/SKILL.md
vendored
9
.github/skills/publish/SKILL.md
vendored
|
|
@ -53,7 +53,8 @@ scp → /opt/bollwerk/data/ └── GET /static/* → Dateie
|
|||
In `app/build.gradle.kts`:
|
||||
|
||||
- `versionCode` um 1 erhöhen
|
||||
- `versionName` passend anpassen (z.B. "1.2" → "1.3")
|
||||
- `versionName` **immer strikt im Format `x.y.z`** führen (z.B. `1.7.13`)
|
||||
- Das dritte Segment (`z`) muss dem `versionCode` entsprechen
|
||||
|
||||
### Schritt 2 – Build & Test
|
||||
|
||||
|
|
@ -112,11 +113,11 @@ Invoke-WebRequest -Uri "https://bollwerk.online/" -UseBasicParsing | Select-Obje
|
|||
Das Skript `publish-apk.py` in diesem Skill-Ordner automatisiert den **gesamten** Release-Workflow (Version bumpen, bauen, hochladen, API-Call, verifizieren, committen):
|
||||
|
||||
```powershell
|
||||
# Alles automatisch (versionCode +1, versionName bleibt):
|
||||
# Alles automatisch (versionCode +1, versionName wird auf x.y.<versionCode> gesetzt):
|
||||
python ".github/skills/publish/publish-apk.py"
|
||||
|
||||
# Mit neuem versionName:
|
||||
python ".github/skills/publish/publish-apk.py" --version-name "2.0"
|
||||
python ".github/skills/publish/publish-apk.py" --version-name "2.0.42"
|
||||
|
||||
# Nur hochladen (APK bereits gebaut, kein Push):
|
||||
python ".github/skills/publish/publish-apk.py" --skip-build --skip-push
|
||||
|
|
@ -131,7 +132,7 @@ python ".github/skills/publish/publish-apk.py" --skip-build --skip-push
|
|||
|
||||
| Parameter | Pflicht | Default | Beschreibung |
|
||||
| ---------------- | ------- | ----------- | ------------------------------------------------- |
|
||||
| `--version-name` | nein | aktueller | Neuer versionName (z.B. "2.0"); sonst unverändert |
|
||||
| `--version-name` | nein | aus current major.minor + code | Neuer versionName im Format `x.y.z` (z.B. "2.0.42") |
|
||||
| `--version-code` | nein | aktuell + 1 | Expliziter versionCode; sonst automatisch +1 |
|
||||
| `--apk-path` | nein | debug-APK | Pfad zur APK-Datei |
|
||||
| `--skip-build` | nein | false | Gradle-Build überspringen |
|
||||
|
|
|
|||
54
.github/skills/publish/publish-apk.ps1
vendored
54
.github/skills/publish/publish-apk.ps1
vendored
|
|
@ -15,7 +15,7 @@
|
|||
- Fehler nach Upload (API) → APK liegt schon oben; kein Rollback, Recovery-Hinweis
|
||||
- Fehler bei Verify/Git → Deployment war erfolgreich; nur Warnung, kein Abbruch
|
||||
.PARAMETER VersionName
|
||||
Neue versionName (z.B. "1.8"). Wenn weggelassen, bleibt die aktuelle.
|
||||
Neue versionName im Format x.y.z (z.B. "1.7.14").
|
||||
.PARAMETER VersionCode
|
||||
Expliziter versionCode. Wenn weggelassen, wird der aktuelle automatisch um 1 erhöht.
|
||||
.PARAMETER ApkPath
|
||||
|
|
@ -72,6 +72,20 @@ function Write-OK { param([string]$Text); Write-Host "[OK] $Text" -ForegroundC
|
|||
function Write-Skip { param([string]$Text); Write-Host "[--] $Text" -ForegroundColor DarkGray }
|
||||
function Write-Fail { param([string]$Text); Write-Host "[!!] $Text" -ForegroundColor Red }
|
||||
|
||||
function Get-MajorMinor {
|
||||
param([string]$VersionName)
|
||||
$m = [regex]::Match($VersionName, '^(\d+)\.(\d+)(?:\.(\d+))?$')
|
||||
if (-not $m.Success) { return $null }
|
||||
return @($m.Groups[1].Value, $m.Groups[2].Value)
|
||||
}
|
||||
|
||||
function Get-ThreePartVersion {
|
||||
param([string]$VersionName)
|
||||
$m = [regex]::Match($VersionName, '^(\d+)\.(\d+)\.(\d+)$')
|
||||
if (-not $m.Success) { return $null }
|
||||
return @([int]$m.Groups[1].Value, [int]$m.Groups[2].Value, [int]$m.Groups[3].Value)
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Pre-Checks (nichts wird verändert)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -105,7 +119,26 @@ if (-not $nameMatch.Success) {
|
|||
$currentCode = [int]$codeMatch.Groups[1].Value
|
||||
$currentName = $nameMatch.Groups[1].Value
|
||||
$newCode = if ($VersionCode -gt 0) { $VersionCode } else { $currentCode + 1 }
|
||||
$newName = if ($VersionName) { $VersionName } else { $currentName }
|
||||
|
||||
if ($VersionName) {
|
||||
$parts = Get-ThreePartVersion $VersionName
|
||||
if (-not $parts) {
|
||||
Write-Fail "-VersionName muss strikt im Format x.y.z angegeben werden (z.B. 1.7.14)."
|
||||
exit 1
|
||||
}
|
||||
if ($parts[2] -ne $newCode) {
|
||||
Write-Fail "Patch-Segment der versionName ($($parts[2])) muss dem versionCode ($newCode) entsprechen."
|
||||
exit 1
|
||||
}
|
||||
$newName = $VersionName
|
||||
} else {
|
||||
$majorMinor = Get-MajorMinor $currentName
|
||||
if (-not $majorMinor) {
|
||||
Write-Fail "Aktuelle versionName '$currentName' hat kein gueltiges Schema. Erlaubt: x.y oder x.y.z"
|
||||
exit 1
|
||||
}
|
||||
$newName = "$($majorMinor[0]).$($majorMinor[1]).$newCode"
|
||||
}
|
||||
|
||||
if ($newCode -le $currentCode) {
|
||||
Write-Fail "Neuer versionCode ($newCode) muss groesser als aktueller ($currentCode) sein."
|
||||
|
|
@ -121,16 +154,14 @@ if ($LASTEXITCODE -ne 0) {
|
|||
Write-OK "Pre-Checks bestanden (Token, Gradle-Parsing, SSH-Agent)"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== Publish APK v$newName (build $newCode) ===" -ForegroundColor Cyan
|
||||
Write-Host " $currentName ($currentCode) → $newName ($newCode)" -ForegroundColor Gray
|
||||
Write-Host "=== Publish APK v$newName ===" -ForegroundColor Cyan
|
||||
Write-Host " $currentName [$currentCode] → $newName [$newCode]" -ForegroundColor Gray
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Schritt 0: build.gradle.kts patchen
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
$patchedContent = $originalContent -replace "versionCode\s*=\s*$currentCode\b", "versionCode = $newCode"
|
||||
if ($VersionName) {
|
||||
$patchedContent = $patchedContent -replace 'versionName\s*=\s*"[^"]+"', "versionName = `"$newName`""
|
||||
}
|
||||
$patchedContent = $patchedContent -replace 'versionName\s*=\s*"[^"]+"', "versionName = `"$newName`""
|
||||
|
||||
try {
|
||||
[System.IO.File]::WriteAllText((Resolve-Path $BuildGradle).Path, $patchedContent)
|
||||
|
|
@ -224,7 +255,7 @@ for ($attempt = 1; $attempt -le $maxRetries; $attempt++) {
|
|||
}
|
||||
|
||||
if ($apiSuccess) {
|
||||
Write-OK "Version gesetzt: $newName (build $newCode)"
|
||||
Write-OK "Version gesetzt: $newName"
|
||||
} else {
|
||||
# Deployment war teilweise erfolgreich → trotzdem committen, aber warnen
|
||||
Write-Host ""
|
||||
|
|
@ -264,10 +295,10 @@ if (-not $apiSuccess -or $SkipVerify) {
|
|||
Write-Step "[Git] Version-Bump committen..."
|
||||
try {
|
||||
git add $BuildGradle
|
||||
git commit -m "chore: release v$newName ($newCode)"
|
||||
git commit -m "chore: release v$newName"
|
||||
} catch {
|
||||
Write-Host "[??] git commit fehlgeschlagen: $_" -ForegroundColor DarkYellow
|
||||
Write-Host " Manuell: git add $BuildGradle ; git commit -m 'chore: release v$newName ($newCode)'" -ForegroundColor DarkGray
|
||||
Write-Host " Manuell: git add $BuildGradle ; git commit -m 'chore: release v$newName'" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
if (-not $SkipPush) {
|
||||
|
|
@ -287,6 +318,7 @@ if (-not $SkipPush) {
|
|||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Host ""
|
||||
Write-Host "=== Publish abgeschlossen ===" -ForegroundColor Cyan
|
||||
Write-Host " Version : $newName (build $newCode)"
|
||||
Write-Host " Version : $newName"
|
||||
Write-Host " Code : $newCode"
|
||||
Write-Host " Homepage: $ServerUrl/"
|
||||
Write-Host " API : $ServerUrl/api/version"
|
||||
|
|
|
|||
122
.github/skills/publish/publish-apk.py
vendored
122
.github/skills/publish/publish-apk.py
vendored
|
|
@ -17,7 +17,7 @@ Rollback-Strategie:
|
|||
|
||||
Verwendung:
|
||||
python publish-apk.py
|
||||
python publish-apk.py --version-name 2.0
|
||||
python publish-apk.py --version-name 2.0.42
|
||||
python publish-apk.py --skip-build --skip-push
|
||||
"""
|
||||
|
||||
|
|
@ -108,13 +108,73 @@ def get_status(url: str, timeout: int = 10):
|
|||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.status
|
||||
|
||||
|
||||
def parse_major_minor(version_name: str):
|
||||
"""Extrahiert major/minor aus x.y oder x.y.z."""
|
||||
m = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?$", version_name.strip())
|
||||
if not m:
|
||||
return None
|
||||
return m.group(1), m.group(2)
|
||||
|
||||
|
||||
def validate_three_part_version(version_name: str):
|
||||
"""Validiert strikt x.y.z (nur numerische Segmente)."""
|
||||
m = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version_name.strip())
|
||||
if not m:
|
||||
return None
|
||||
return int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||
|
||||
|
||||
def _extract_admin_token(raw_text: str) -> str:
|
||||
"""Extrahiert BOLLWERK_ADMIN_TOKEN aus docker-compose/.env-Inhalten."""
|
||||
if not raw_text:
|
||||
return ""
|
||||
|
||||
patterns = [
|
||||
r"(?m)^\s*BOLLWERK_ADMIN_TOKEN\s*[:=]\s*['\"]?([^'\"\s#]+)",
|
||||
r"(?m)^\s*-\s*BOLLWERK_ADMIN_TOKEN\s*[:=]\s*['\"]?([^'\"\s#]+)",
|
||||
r"(?m)^\s*-\s*BOLLWERK_ADMIN_TOKEN\s*=\s*['\"]?([^'\"\s#]+)",
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, raw_text)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def get_admin_token_from_vps() -> str:
|
||||
"""Holt den Admin-Token direkt vom VPS (Dateien, dann Container-Env)."""
|
||||
ssh_opts = "-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no"
|
||||
|
||||
probe_commands = [
|
||||
(
|
||||
"compose+env files",
|
||||
f'ssh {ssh_opts} {VPS} '
|
||||
f'"cd {REMOTE_DIR}; '
|
||||
f'[ -f docker-compose.yml ] && cat docker-compose.yml || true; '
|
||||
f'echo __ENV_SPLIT__; '
|
||||
f'[ -f .env ] && cat .env || true"',
|
||||
),
|
||||
(
|
||||
"docker inspect env",
|
||||
f'ssh {ssh_opts} {VPS} "docker inspect $(docker ps -q) 2>/dev/null || true"',
|
||||
),
|
||||
]
|
||||
|
||||
for _, cmd in probe_commands:
|
||||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
||||
token = _extract_admin_token(result.stdout or "")
|
||||
if token:
|
||||
return token
|
||||
return ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argument-Parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Bollwerk APK Publish-Workflow")
|
||||
parser.add_argument("--version-name", default="", help="Neue versionName (z.B. '1.8')")
|
||||
parser.add_argument("--version-name", default="", help="Neue versionName im Format x.y.z (z.B. '1.7.14')")
|
||||
parser.add_argument("--version-code", type=int, default=0, help="Expliziter versionCode")
|
||||
parser.add_argument("--apk-path", default="app/build/outputs/apk/debug/app-debug.apk")
|
||||
parser.add_argument("--skip-build", action="store_true")
|
||||
|
|
@ -129,13 +189,6 @@ def parse_args():
|
|||
def main():
|
||||
args = parse_args()
|
||||
|
||||
# --- Pre-Check: Admin-Token ---
|
||||
admin_token = os.environ.get("BOLLWERK_ADMIN_TOKEN", "")
|
||||
if not admin_token:
|
||||
fail("BOLLWERK_ADMIN_TOKEN ist nicht gesetzt.")
|
||||
info("Bitte setzen: $env:BOLLWERK_ADMIN_TOKEN = 'dein-token'")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Pre-Check: build.gradle.kts lesen ---
|
||||
if not os.path.exists(BUILD_GRADLE):
|
||||
fail(f"Datei nicht gefunden: {BUILD_GRADLE}")
|
||||
|
|
@ -156,7 +209,22 @@ def main():
|
|||
current_code = int(code_match.group(1))
|
||||
current_name = name_match.group(1)
|
||||
new_code = args.version_code if args.version_code > 0 else current_code + 1
|
||||
new_name = args.version_name if args.version_name else current_name
|
||||
|
||||
if args.version_name:
|
||||
parsed = validate_three_part_version(args.version_name)
|
||||
if not parsed:
|
||||
fail("--version-name muss strikt im Format x.y.z angegeben werden (z. B. 1.7.14).")
|
||||
sys.exit(1)
|
||||
if parsed[2] != new_code:
|
||||
fail(f"Patch-Segment der versionName ({parsed[2]}) muss dem versionCode ({new_code}) entsprechen.")
|
||||
sys.exit(1)
|
||||
new_name = args.version_name.strip()
|
||||
else:
|
||||
major_minor = parse_major_minor(current_name)
|
||||
if not major_minor:
|
||||
fail(f"Aktuelle versionName '{current_name}' hat kein gueltiges Schema. Erlaubt: x.y oder x.y.z")
|
||||
sys.exit(1)
|
||||
new_name = f"{major_minor[0]}.{major_minor[1]}.{new_code}"
|
||||
|
||||
if new_code <= current_code:
|
||||
fail(f"Neuer versionCode ({new_code}) muss groesser als aktueller ({current_code}) sein.")
|
||||
|
|
@ -168,10 +236,20 @@ def main():
|
|||
fail("SSH-Agent hat keinen Key. Bitte ausfuehren: ssh-add C:\\Users\\JensR\\.ssh\\id_ed25519")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Pre-Check: Admin-Token direkt vom VPS holen ---
|
||||
step("[Preflight] Lade BOLLWERK_ADMIN_TOKEN direkt vom VPS...")
|
||||
admin_token = get_admin_token_from_vps()
|
||||
if admin_token:
|
||||
ok("Admin-Token vom VPS geladen")
|
||||
else:
|
||||
fail("BOLLWERK_ADMIN_TOKEN konnte auf dem VPS nicht ermittelt werden.")
|
||||
info(f"Pruefe auf {REMOTE_DIR}/docker-compose.yml, {REMOTE_DIR}/.env oder Container-Env")
|
||||
sys.exit(1)
|
||||
|
||||
ok("Pre-Checks bestanden (Token, Gradle-Parsing, SSH-Agent)")
|
||||
|
||||
print(f"\n{CYAN}=== Publish APK v{new_name} (build {new_code}) ==={RESET}", flush=True)
|
||||
print(f"{GRAY} {current_name} ({current_code}) -> {new_name} ({new_code}){RESET}", flush=True)
|
||||
print(f"\n{CYAN}=== Publish APK v{new_name} ==={RESET}", flush=True)
|
||||
print(f"{GRAY} {current_name} [{current_code}] -> {new_name} [{new_code}]{RESET}", flush=True)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Schritt 0: build.gradle.kts patchen
|
||||
|
|
@ -181,12 +259,11 @@ def main():
|
|||
f'versionCode = {new_code}',
|
||||
original_content,
|
||||
)
|
||||
if args.version_name:
|
||||
patched = re.sub(
|
||||
r'versionName\s*=\s*"[^"]+"',
|
||||
f'versionName = "{new_name}"',
|
||||
patched,
|
||||
)
|
||||
patched = re.sub(
|
||||
r'versionName\s*=\s*"[^"]+"',
|
||||
f'versionName = "{new_name}"',
|
||||
patched,
|
||||
)
|
||||
|
||||
try:
|
||||
with open(BUILD_GRADLE, "w", encoding="utf-8") as f:
|
||||
|
|
@ -270,7 +347,7 @@ def main():
|
|||
info(f" -d '{json.dumps(api_body)}'")
|
||||
|
||||
if api_success:
|
||||
ok(f"Version gesetzt: {new_name} (build {new_code})")
|
||||
ok(f"Version gesetzt: {new_name}")
|
||||
else:
|
||||
warn("VPS-Version nicht aktualisiert. build.gradle.kts wird trotzdem committet.")
|
||||
|
||||
|
|
@ -304,10 +381,10 @@ def main():
|
|||
step("[Git] Version-Bump committen...")
|
||||
try:
|
||||
run(f'git add "{BUILD_GRADLE}"')
|
||||
run(f'git commit -m "chore: release v{new_name} ({new_code})"')
|
||||
run(f'git commit -m "chore: release v{new_name}"')
|
||||
except Exception as e:
|
||||
warn(f"git commit fehlgeschlagen: {e}")
|
||||
info(f'Manuell: git add "{BUILD_GRADLE}" ; git commit -m "chore: release v{new_name} ({new_code})"')
|
||||
info(f'Manuell: git add "{BUILD_GRADLE}" ; git commit -m "chore: release v{new_name}"')
|
||||
|
||||
if not args.skip_push:
|
||||
try:
|
||||
|
|
@ -323,7 +400,8 @@ def main():
|
|||
# Zusammenfassung
|
||||
# -------------------------------------------------------------------------
|
||||
print(f"\n{CYAN}=== Publish abgeschlossen ==={RESET}", flush=True)
|
||||
print(f" Version : {new_name} (build {new_code})", flush=True)
|
||||
print(f" Version : {new_name}", flush=True)
|
||||
print(f" Code : {new_code}", flush=True)
|
||||
print(f" Homepage: {SERVER_URL}/", flush=True)
|
||||
print(f" API : {SERVER_URL}/api/version", flush=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ android {
|
|||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 13
|
||||
versionName = "1.7"
|
||||
versionName = "1.7.13"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
buildConfigField("boolean", "FEATURE_CAMERA_ENABLED", "false")
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@
|
|||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<application
|
||||
android:name=".BollwerkApp"
|
||||
|
|
@ -37,10 +35,6 @@
|
|||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name=".service.MessagingService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -32,9 +32,12 @@ class MainActivity : ComponentActivity() {
|
|||
|
||||
@Inject internal lateinit var seedDatabaseUseCase: SeedDatabaseUseCase
|
||||
@Inject internal lateinit var ensureKeyPairUseCase: EnsureKeyPairUseCase
|
||||
@Inject internal lateinit var notificationHelper: NotificationHelper
|
||||
|
||||
private val _chatNavigationEvents = MutableSharedFlow<ChatDeepLink>(extraBufferCapacity = 1)
|
||||
val chatNavigationEvents: SharedFlow<ChatDeepLink> = _chatNavigationEvents.asSharedFlow()
|
||||
private val _notificationNavigationEvents =
|
||||
MutableSharedFlow<NotificationNavigationEvent>(extraBufferCapacity = 1)
|
||||
private val notificationNavigationEvents: SharedFlow<NotificationNavigationEvent> =
|
||||
_notificationNavigationEvents.asSharedFlow()
|
||||
|
||||
private val notificationPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
|
|
@ -46,10 +49,10 @@ class MainActivity : ComponentActivity() {
|
|||
lifecycleScope.launch { ensureKeyPairUseCase.execute() }
|
||||
enableEdgeToEdge()
|
||||
requestNotificationPermissionIfNeeded()
|
||||
handleChatDeepLink(intent)
|
||||
handleNotificationDeepLink(intent)
|
||||
setContent {
|
||||
BollwerkTheme {
|
||||
MainScreen(chatNavigationEvents = chatNavigationEvents)
|
||||
MainScreen(notificationNavigationEvents = notificationNavigationEvents)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -66,22 +69,34 @@ class MainActivity : ComponentActivity() {
|
|||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleChatDeepLink(intent)
|
||||
handleNotificationDeepLink(intent)
|
||||
}
|
||||
|
||||
private fun handleChatDeepLink(intent: Intent?) {
|
||||
private fun handleNotificationDeepLink(intent: Intent?) {
|
||||
val recipientId = intent?.getStringExtra(NotificationHelper.EXTRA_RECIPIENT_ID)
|
||||
val recipientUsername = intent?.getStringExtra(NotificationHelper.EXTRA_RECIPIENT_USERNAME)
|
||||
if (recipientId != null && recipientUsername != null) {
|
||||
_chatNavigationEvents.tryEmit(ChatDeepLink(recipientId, recipientUsername))
|
||||
_notificationNavigationEvents.tryEmit(
|
||||
NotificationNavigationEvent.OpenChat(recipientId, recipientUsername)
|
||||
)
|
||||
// Clear extras to prevent re-navigation on config change
|
||||
intent?.removeExtra(NotificationHelper.EXTRA_RECIPIENT_ID)
|
||||
intent?.removeExtra(NotificationHelper.EXTRA_RECIPIENT_USERNAME)
|
||||
return
|
||||
}
|
||||
|
||||
if (intent?.getBooleanExtra(NotificationHelper.EXTRA_OPEN_MESSAGES, false) == true) {
|
||||
notificationHelper.cancelAllMessageNotifications()
|
||||
_notificationNavigationEvents.tryEmit(NotificationNavigationEvent.OpenMessages)
|
||||
intent.removeExtra(NotificationHelper.EXTRA_OPEN_MESSAGES)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ChatDeepLink(val recipientId: String, val recipientUsername: String)
|
||||
internal sealed interface NotificationNavigationEvent {
|
||||
data class OpenChat(val recipientId: String, val recipientUsername: String) : NotificationNavigationEvent
|
||||
data object OpenMessages : NotificationNavigationEvent
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ internal class NotificationHelper @Inject constructor(
|
|||
|
||||
@Volatile
|
||||
private var activeChatPartnerId: String? = null
|
||||
@Volatile
|
||||
private var isMessagingAreaVisible: Boolean = false
|
||||
private val activeSenderNotificationIds = mutableSetOf<Int>()
|
||||
private val activeSenderNamesById = mutableMapOf<Int, String>()
|
||||
|
||||
/// Erstellt den Notification Channel (ab API 26 erforderlich).
|
||||
fun createNotificationChannel() {
|
||||
|
|
@ -50,24 +54,41 @@ internal class NotificationHelper @Inject constructor(
|
|||
activeChatPartnerId = partnerId
|
||||
}
|
||||
|
||||
/// Setzt, ob der Nutzer aktuell im Nachrichtenbereich ist (User-Liste oder Chat).
|
||||
fun setMessagingAreaVisible(isVisible: Boolean) {
|
||||
isMessagingAreaVisible = isVisible
|
||||
}
|
||||
|
||||
/// Zeigt eine Benachrichtigung für eine eingehende Nachricht an.
|
||||
/// Gibt false zurück, wenn die Notification unterdrückt wurde (Chat ist aktiv).
|
||||
fun showNewMessageNotification(
|
||||
senderId: String,
|
||||
senderUsername: String
|
||||
): Boolean {
|
||||
if (senderId == activeChatPartnerId) return false
|
||||
if (isMessagingAreaVisible || senderId == activeChatPartnerId) return false
|
||||
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
val chatIntent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(EXTRA_RECIPIENT_ID, senderId)
|
||||
putExtra(EXTRA_RECIPIENT_USERNAME, senderUsername)
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
val chatPendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
senderId.hashCode(),
|
||||
intent,
|
||||
chatIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val messagesIntent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(EXTRA_OPEN_MESSAGES, true)
|
||||
}
|
||||
|
||||
val messagesPendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
SUMMARY_NOTIFICATION_ID,
|
||||
messagesIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
|
|
@ -77,23 +98,38 @@ internal class NotificationHelper @Inject constructor(
|
|||
.setContentText("$senderUsername hat etwas geschrieben")
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setContentIntent(chatPendingIntent)
|
||||
.setGroup(GROUP_KEY)
|
||||
.build()
|
||||
|
||||
val summaryNotification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification_message)
|
||||
.setContentTitle("Neue Nachrichten")
|
||||
.setContentText("Du hast neue Nachrichten")
|
||||
.setGroup(GROUP_KEY)
|
||||
.setGroupSummary(true)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
val senderNotificationId = senderId.hashCode()
|
||||
val notificationManager = NotificationManagerCompat.from(context)
|
||||
try {
|
||||
notificationManager.notify(senderId.hashCode(), notification)
|
||||
notificationManager.notify(SUMMARY_NOTIFICATION_ID, summaryNotification)
|
||||
val (shouldShowSummary, summaryText) = synchronized(activeSenderNotificationIds) {
|
||||
activeSenderNotificationIds.add(senderNotificationId)
|
||||
activeSenderNamesById[senderNotificationId] = senderUsername
|
||||
Pair(
|
||||
activeSenderNotificationIds.size > 1,
|
||||
buildSummaryText(activeSenderNamesById.values.toList())
|
||||
)
|
||||
}
|
||||
|
||||
val summaryNotification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification_message)
|
||||
.setContentTitle("Neue Nachrichten")
|
||||
.setContentText(summaryText)
|
||||
.setGroup(GROUP_KEY)
|
||||
.setGroupSummary(true)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(messagesPendingIntent)
|
||||
.build()
|
||||
|
||||
notificationManager.notify(senderNotificationId, notification)
|
||||
if (shouldShowSummary) {
|
||||
notificationManager.notify(SUMMARY_NOTIFICATION_ID, summaryNotification)
|
||||
} else {
|
||||
notificationManager.cancel(SUMMARY_NOTIFICATION_ID)
|
||||
}
|
||||
} catch (_: SecurityException) {
|
||||
// Permission not granted – ignore silently
|
||||
return false
|
||||
|
|
@ -104,7 +140,41 @@ internal class NotificationHelper @Inject constructor(
|
|||
/// Entfernt Benachrichtigungen für einen bestimmten Absender (wenn der Chat geöffnet wird).
|
||||
fun cancelNotificationForSender(senderId: String) {
|
||||
val notificationManager = NotificationManagerCompat.from(context)
|
||||
notificationManager.cancel(senderId.hashCode())
|
||||
val senderNotificationId = senderId.hashCode()
|
||||
notificationManager.cancel(senderNotificationId)
|
||||
val hasMoreSenders = synchronized(activeSenderNotificationIds) {
|
||||
activeSenderNotificationIds.remove(senderNotificationId)
|
||||
activeSenderNamesById.remove(senderNotificationId)
|
||||
activeSenderNotificationIds.isNotEmpty()
|
||||
}
|
||||
if (!hasMoreSenders) {
|
||||
notificationManager.cancel(SUMMARY_NOTIFICATION_ID)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelAllMessageNotifications() {
|
||||
val notificationManager = NotificationManagerCompat.from(context)
|
||||
val ids = synchronized(activeSenderNotificationIds) {
|
||||
val copy = activeSenderNotificationIds.toList()
|
||||
activeSenderNotificationIds.clear()
|
||||
activeSenderNamesById.clear()
|
||||
copy
|
||||
}
|
||||
ids.forEach { notificationManager.cancel(it) }
|
||||
notificationManager.cancel(SUMMARY_NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
private fun buildSummaryText(senderNames: List<String>): String {
|
||||
val uniqueSenders = senderNames.distinct()
|
||||
return when (uniqueSenders.size) {
|
||||
0 -> "Neue Nachrichten"
|
||||
1 -> "Neue Nachricht von ${uniqueSenders[0]}"
|
||||
2 -> "Neue Nachrichten von ${uniqueSenders[0]} und ${uniqueSenders[1]}"
|
||||
else -> {
|
||||
val remaining = uniqueSenders.size - 2
|
||||
"Neue Nachrichten von ${uniqueSenders[0]}, ${uniqueSenders[1]} und $remaining weiteren"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
@ -115,5 +185,6 @@ internal class NotificationHelper @Inject constructor(
|
|||
|
||||
const val EXTRA_RECIPIENT_ID = "notification_recipient_id"
|
||||
const val EXTRA_RECIPIENT_USERNAME = "notification_recipient_username"
|
||||
const val EXTRA_OPEN_MESSAGES = "notification_open_messages"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,88 +0,0 @@
|
|||
package de.bollwerk.app.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import de.bollwerk.app.MainActivity
|
||||
import de.bollwerk.app.R
|
||||
|
||||
/**
|
||||
* Foreground Service der den Prozess am Leben hält, damit die WebSocket-Verbindung
|
||||
* auch im Hintergrund bestehen bleibt und Nachrichten empfangen werden können.
|
||||
*/
|
||||
class MessagingService : Service() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createServiceChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
startForeground(NOTIFICATION_ID, buildNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun createServiceChannel() {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Verbindung",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Hält die Verbindung zum Server für Nachrichten aufrecht"
|
||||
setShowBadge(false)
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val openIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification_message)
|
||||
.setContentTitle("Bollwerk")
|
||||
.setContentText("Verbunden – Nachrichten werden empfangen")
|
||||
.setOngoing(true)
|
||||
.setContentIntent(openIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "bollwerk_service"
|
||||
private const val NOTIFICATION_ID = 9001
|
||||
const val ACTION_STOP = "de.bollwerk.app.STOP_MESSAGING_SERVICE"
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, MessagingService::class.java)
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
val intent = Intent(context, MessagingService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -35,7 +35,7 @@ import androidx.navigation.NavDestination.Companion.hasRoute
|
|||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import de.bollwerk.app.ChatDeepLink
|
||||
import de.bollwerk.app.NotificationNavigationEvent
|
||||
import de.bollwerk.app.ui.inventory.InventoryPickerSheet
|
||||
import de.bollwerk.app.ui.inventory.InventoryPickerViewModel
|
||||
import de.bollwerk.app.ui.navigation.BollwerkNavGraph
|
||||
|
|
@ -48,7 +48,7 @@ import kotlinx.coroutines.flow.SharedFlow
|
|||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun MainScreen(chatNavigationEvents: SharedFlow<ChatDeepLink>) {
|
||||
internal fun MainScreen(notificationNavigationEvents: SharedFlow<NotificationNavigationEvent>) {
|
||||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
|
@ -67,15 +67,32 @@ internal fun MainScreen(chatNavigationEvents: SharedFlow<ChatDeepLink>) {
|
|||
}
|
||||
}
|
||||
|
||||
val isInMessagingArea =
|
||||
currentDestination?.hasRoute(Screen.UserList::class) == true ||
|
||||
currentDestination?.hasRoute(Screen.Chat::class) == true
|
||||
LaunchedEffect(isInMessagingArea) {
|
||||
mainViewModel.setMessagingAreaVisible(isInMessagingArea)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
chatNavigationEvents.collect { deepLink ->
|
||||
navController.navigate(
|
||||
Screen.Chat(
|
||||
recipientId = deepLink.recipientId,
|
||||
recipientUsername = deepLink.recipientUsername
|
||||
)
|
||||
) {
|
||||
launchSingleTop = true
|
||||
notificationNavigationEvents.collect { event ->
|
||||
when (event) {
|
||||
is NotificationNavigationEvent.OpenChat -> {
|
||||
navController.navigate(
|
||||
Screen.Chat(
|
||||
recipientId = event.recipientId,
|
||||
recipientUsername = event.recipientUsername
|
||||
)
|
||||
) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
|
||||
NotificationNavigationEvent.OpenMessages -> {
|
||||
navController.navigate(Screen.UserList) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
package de.bollwerk.app.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.bollwerk.app.data.sync.WebSocketClient
|
||||
import de.bollwerk.app.data.sync.WebSocketEvent
|
||||
import de.bollwerk.app.domain.AuthEventBus
|
||||
|
|
@ -13,7 +11,7 @@ import de.bollwerk.app.domain.model.SettingsKey.StringKey
|
|||
import de.bollwerk.app.domain.repository.ImportExportRepository
|
||||
import de.bollwerk.app.domain.repository.SettingsRepository
|
||||
import de.bollwerk.app.domain.repository.SyncService
|
||||
import de.bollwerk.app.service.MessagingService
|
||||
import de.bollwerk.app.notification.NotificationHelper
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
|
@ -28,7 +26,7 @@ internal class MainViewModel @Inject constructor(
|
|||
private val webSocketClient: WebSocketClient,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val importExportRepository: ImportExportRepository,
|
||||
@ApplicationContext private val context: Context
|
||||
private val notificationHelper: NotificationHelper
|
||||
) : ViewModel() {
|
||||
|
||||
private val _navigateToSettings = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
|
|
@ -41,6 +39,10 @@ internal class MainViewModel @Inject constructor(
|
|||
observeWebSocketEvents()
|
||||
}
|
||||
|
||||
fun setMessagingAreaVisible(isVisible: Boolean) {
|
||||
notificationHelper.setMessagingAreaVisible(isVisible)
|
||||
}
|
||||
|
||||
/** Beim App-Start: wenn Token + Server-URL vorhanden → WebSocket verbinden */
|
||||
private fun connectOnStartup() {
|
||||
viewModelScope.launch {
|
||||
|
|
@ -49,7 +51,6 @@ internal class MainViewModel @Inject constructor(
|
|||
if (token.isNotBlank() && serverUrl.isNotBlank()) {
|
||||
Log.i(TAG, "App-Start: Token vorhanden – verbinde WebSocket")
|
||||
webSocketClient.connect(serverUrl, token)
|
||||
MessagingService.start(context)
|
||||
} else {
|
||||
Log.d(TAG, "App-Start: Kein Token – kein WebSocket")
|
||||
}
|
||||
|
|
@ -62,7 +63,6 @@ internal class MainViewModel @Inject constructor(
|
|||
authEventBus.loginSuccess.collect { (serverUrl, token) ->
|
||||
Log.i(TAG, "Login erfolgreich – verbinde WebSocket")
|
||||
webSocketClient.connect(serverUrl, token)
|
||||
MessagingService.start(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -74,7 +74,6 @@ internal class MainViewModel @Inject constructor(
|
|||
Log.w(TAG, "Session abgelaufen – Forced-Logout")
|
||||
syncService.logout()
|
||||
webSocketClient.disconnect()
|
||||
MessagingService.stop(context)
|
||||
_navigateToSettings.emit(Unit)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import de.bollwerk.app.domain.model.toJson
|
|||
import de.bollwerk.app.domain.repository.ImportExportRepository
|
||||
import de.bollwerk.app.domain.repository.SettingsRepository
|
||||
import de.bollwerk.app.domain.repository.SyncService
|
||||
import de.bollwerk.app.service.MessagingService
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -231,7 +230,6 @@ internal class SettingsViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
syncService.logout()
|
||||
webSocketClient.disconnect()
|
||||
MessagingService.stop(context)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoggedIn = false,
|
||||
|
|
|
|||
394
knowledge-conduit/kc_generaltest.py
Normal file
394
knowledge-conduit/kc_generaltest.py
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Knowledge-Conduit Generaltest Harness.
|
||||
|
||||
Ziele:
|
||||
- kleine/grosse Aenderungen
|
||||
- mit/ohne Noise
|
||||
- 1 oder 4 Capabilities gleichzeitig
|
||||
- zusaetzliche Mutationstypen fuer realistische Drifts
|
||||
- deterministischer Seed je Szenario
|
||||
- Gold-Expected-Files und Metriken
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import random
|
||||
import shutil
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
FIXTURES_DIR = SCRIPT_DIR / "fixtures"
|
||||
DEFAULT_OUT_DIR = SCRIPT_DIR / "artifacts"
|
||||
|
||||
REPOS = ["repo-alpha", "repo-beta"]
|
||||
SKILLS = [
|
||||
Path(".github/skills/kc-dataset-cleaner/SKILL.md"),
|
||||
Path(".github/skills/kc-api-smoke/SKILL.md"),
|
||||
Path(".github/skills/kc-release-notes/SKILL.md"),
|
||||
]
|
||||
|
||||
BASE_CAPABILITIES = [
|
||||
"input-validation",
|
||||
"response-schema-check",
|
||||
"retry-backoff",
|
||||
"latency-budget",
|
||||
"auth-guard",
|
||||
"idempotency-check",
|
||||
]
|
||||
|
||||
IMPROVED_CAPABILITIES = [
|
||||
"strict-input-validation",
|
||||
"response-contract-check",
|
||||
"retry-jitter-backoff",
|
||||
"p95-latency-budget",
|
||||
"token-scope-guard",
|
||||
"idempotency-key-replay-check",
|
||||
]
|
||||
|
||||
TRIGGER_SYNONYMS = {
|
||||
"Bereinige den Datensatz": "Bereinige den Input-Bestand",
|
||||
"Normalisiere diese CSV": "Standardisiere diese CSV",
|
||||
"Finde Dubletten und fehlende Felder": "Erkenne Duplikate und Null-Felder",
|
||||
"Starte API-Smoke-Test": "Starte API-Basischeck",
|
||||
"Pruefe Health- und Auth-Endpunkte": "Validiere Health- und Auth-Routen",
|
||||
"Validiere Basis-Responses": "Pruefe Grundantworten",
|
||||
"Schreibe Release Notes": "Erstelle Release Notes",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scenario:
|
||||
scenario_id: str
|
||||
repo: str
|
||||
skill: str
|
||||
size: str
|
||||
noise: bool
|
||||
capabilities_to_change: int
|
||||
change_kind: str
|
||||
seed: int
|
||||
|
||||
|
||||
def scenario_seed(material: str) -> int:
|
||||
digest = hashlib.sha256(material.encode("utf-8")).hexdigest()
|
||||
return int(digest[:8], 16)
|
||||
|
||||
|
||||
def ensure_capability_section(text: str) -> str:
|
||||
if "## Capabilities" in text:
|
||||
return text
|
||||
|
||||
marker = "## Checkliste"
|
||||
section = ["## Capabilities", ""]
|
||||
for cap in BASE_CAPABILITIES:
|
||||
section.append(f"- {cap}")
|
||||
section.append("")
|
||||
insertion = "\n".join(section)
|
||||
|
||||
if marker in text:
|
||||
return text.replace(marker, f"{insertion}\n{marker}", 1)
|
||||
return text.rstrip() + "\n\n" + insertion + "\n"
|
||||
|
||||
|
||||
def mutate_capabilities(text: str, count: int) -> Tuple[str, int]:
|
||||
text = ensure_capability_section(text)
|
||||
lines = text.splitlines()
|
||||
|
||||
cap_start = None
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == "## Capabilities":
|
||||
cap_start = i + 1
|
||||
break
|
||||
if cap_start is None:
|
||||
return text, 0
|
||||
|
||||
bullets: List[int] = []
|
||||
for i in range(cap_start, len(lines)):
|
||||
striped = lines[i].strip()
|
||||
if striped.startswith("## "):
|
||||
break
|
||||
if striped.startswith("- "):
|
||||
bullets.append(i)
|
||||
|
||||
replace_count = min(count, len(bullets), len(IMPROVED_CAPABILITIES))
|
||||
for idx in range(replace_count):
|
||||
lines[bullets[idx]] = f"- {IMPROVED_CAPABILITIES[idx]}"
|
||||
|
||||
return "\n".join(lines) + "\n", replace_count
|
||||
|
||||
|
||||
def add_content_or_structure_hint(text: str, change_kind: str) -> str:
|
||||
if change_kind == "structure-tune":
|
||||
return text.replace("## Zweck", "## Zweck\n\nHinweis: Struktur-Tune aktiv.", 1)
|
||||
return text.replace("## Zweck", "## Zweck\n\nHinweis: Content-Tune aktiv.", 1)
|
||||
|
||||
|
||||
def reorder_sections(text: str) -> str:
|
||||
if "## Trigger-Phrasen" not in text or "## Checkliste" not in text:
|
||||
return text
|
||||
start_trigger = text.index("## Trigger-Phrasen")
|
||||
start_check = text.index("## Checkliste")
|
||||
if start_check < start_trigger:
|
||||
return text
|
||||
head = text[:start_trigger]
|
||||
trigger_part = text[start_trigger:start_check]
|
||||
rest = text[start_check:]
|
||||
if "\n## " in rest:
|
||||
check_part = rest.split("\n## ", 1)[0]
|
||||
tail = "\n## " + rest.split("\n## ", 1)[1]
|
||||
else:
|
||||
check_part = rest
|
||||
tail = ""
|
||||
return head + check_part.rstrip() + "\n\n" + trigger_part.strip() + "\n" + tail
|
||||
|
||||
|
||||
def apply_trigger_synonyms(text: str) -> str:
|
||||
for old, new in TRIGGER_SYNONYMS.items():
|
||||
text = text.replace(old, new)
|
||||
return text
|
||||
|
||||
|
||||
def inject_noise(repo_dir: Path, scenario_id: str, seed: int) -> None:
|
||||
rnd = random.Random(seed)
|
||||
noise_dir = repo_dir / "noise" / scenario_id
|
||||
noise_dir.mkdir(parents=True, exist_ok=True)
|
||||
(noise_dir / "notes.txt").write_text("noise payload\nignore me\n", encoding="utf-8")
|
||||
payload = {
|
||||
"scenario": scenario_id,
|
||||
"kind": "noise",
|
||||
"seed": seed,
|
||||
"checksum_hint": rnd.randint(1000, 9999),
|
||||
}
|
||||
(noise_dir / "payload.json").write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def build_scenarios() -> List[Scenario]:
|
||||
sizes = ["small", "large"]
|
||||
noises = [False, True]
|
||||
capability_counts = [1, 4]
|
||||
change_kinds = [
|
||||
"content-tune",
|
||||
"structure-tune",
|
||||
"rename-skill",
|
||||
"reorder-sections",
|
||||
"trigger-synonyms",
|
||||
]
|
||||
|
||||
scenarios: List[Scenario] = []
|
||||
index = 1
|
||||
for repo, skill, size, noise, cap_count, kind in itertools.product(
|
||||
REPOS, SKILLS, sizes, noises, capability_counts, change_kinds
|
||||
):
|
||||
material = "|".join([repo, str(skill), size, str(noise), str(cap_count), kind])
|
||||
scenarios.append(
|
||||
Scenario(
|
||||
scenario_id=f"KC-{index:03d}",
|
||||
repo=repo,
|
||||
skill=str(skill).replace("\\", "/"),
|
||||
size=size,
|
||||
noise=noise,
|
||||
capabilities_to_change=cap_count,
|
||||
change_kind=kind,
|
||||
seed=scenario_seed(material),
|
||||
)
|
||||
)
|
||||
index += 1
|
||||
return scenarios
|
||||
|
||||
|
||||
def error_signature(exc: Exception) -> str:
|
||||
text = f"{type(exc).__name__}: {exc}"
|
||||
return text[:160]
|
||||
|
||||
|
||||
def apply_scenarios(
|
||||
scenarios: List[Scenario], out_dir: Path, fixtures_dir: Path
|
||||
) -> Dict[str, object]:
|
||||
runs_dir = out_dir / "runs"
|
||||
expected_dir = out_dir / "expected"
|
||||
if runs_dir.exists():
|
||||
shutil.rmtree(runs_dir)
|
||||
if expected_dir.exists():
|
||||
shutil.rmtree(expected_dir)
|
||||
runs_dir.mkdir(parents=True, exist_ok=True)
|
||||
expected_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results: List[Dict[str, object]] = []
|
||||
errors: List[Dict[str, str]] = []
|
||||
runtime_by_class: Dict[str, List[float]] = {}
|
||||
|
||||
for scenario in scenarios:
|
||||
start = time.perf_counter()
|
||||
class_key = "|".join(
|
||||
[
|
||||
scenario.size,
|
||||
"noise" if scenario.noise else "clean",
|
||||
f"cap{scenario.capabilities_to_change}",
|
||||
scenario.change_kind,
|
||||
]
|
||||
)
|
||||
|
||||
try:
|
||||
source_repo = fixtures_dir / scenario.repo
|
||||
if not source_repo.exists():
|
||||
raise FileNotFoundError(f"fixture repo missing: {source_repo}")
|
||||
|
||||
scenario_root = runs_dir / scenario.scenario_id
|
||||
target_repo = scenario_root / scenario.repo
|
||||
shutil.copytree(source_repo, target_repo)
|
||||
|
||||
skill_path = target_repo / Path(scenario.skill)
|
||||
if not skill_path.exists():
|
||||
raise FileNotFoundError(f"skill missing: {skill_path}")
|
||||
|
||||
original = skill_path.read_text(encoding="utf-8")
|
||||
changed, replaced_count = mutate_capabilities(original, scenario.capabilities_to_change)
|
||||
|
||||
if scenario.change_kind in ("content-tune", "structure-tune"):
|
||||
changed = add_content_or_structure_hint(changed, scenario.change_kind)
|
||||
elif scenario.change_kind == "reorder-sections":
|
||||
changed = reorder_sections(changed)
|
||||
elif scenario.change_kind == "trigger-synonyms":
|
||||
changed = apply_trigger_synonyms(changed)
|
||||
|
||||
final_skill_path = skill_path
|
||||
if scenario.change_kind == "rename-skill":
|
||||
final_skill_path = skill_path.with_name("SKILL_RENAMED.md")
|
||||
skill_path.unlink()
|
||||
|
||||
if scenario.size == "large":
|
||||
changed += (
|
||||
"\n## Erweiterte Testnotiz\n"
|
||||
"- Fuehre den gleichen Vorgang mit 3 Umgebungen aus.\n"
|
||||
"- Vergleiche Diff, Laufzeit und Fehlersignaturen.\n"
|
||||
)
|
||||
|
||||
final_skill_path.write_text(changed, encoding="utf-8")
|
||||
|
||||
if scenario.noise:
|
||||
inject_noise(target_repo, scenario.scenario_id, scenario.seed)
|
||||
|
||||
runtime_ms = round((time.perf_counter() - start) * 1000, 3)
|
||||
runtime_by_class.setdefault(class_key, []).append(runtime_ms)
|
||||
|
||||
expected_payload = {
|
||||
"scenario_id": scenario.scenario_id,
|
||||
"seed": scenario.seed,
|
||||
"repo": scenario.repo,
|
||||
"skill_input": scenario.skill,
|
||||
"skill_output": str(final_skill_path.relative_to(target_repo)).replace("\\", "/"),
|
||||
"change_kind": scenario.change_kind,
|
||||
"size": scenario.size,
|
||||
"noise": scenario.noise,
|
||||
"expected_capability_replacements": replaced_count,
|
||||
"capabilities_requested": scenario.capabilities_to_change,
|
||||
"status": "ok",
|
||||
}
|
||||
(expected_dir / f"{scenario.scenario_id}.expected.json").write_text(
|
||||
json.dumps(expected_payload, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
results.append(
|
||||
{
|
||||
"scenario_id": scenario.scenario_id,
|
||||
"runtime_ms": runtime_ms,
|
||||
"class_key": class_key,
|
||||
"status": "ok",
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
runtime_ms = round((time.perf_counter() - start) * 1000, 3)
|
||||
runtime_by_class.setdefault(class_key, []).append(runtime_ms)
|
||||
signature = error_signature(exc)
|
||||
errors.append({"scenario_id": scenario.scenario_id, "signature": signature})
|
||||
results.append(
|
||||
{
|
||||
"scenario_id": scenario.scenario_id,
|
||||
"runtime_ms": runtime_ms,
|
||||
"class_key": class_key,
|
||||
"status": "error",
|
||||
"error_signature": signature,
|
||||
}
|
||||
)
|
||||
|
||||
class_metrics = {}
|
||||
for key, values in runtime_by_class.items():
|
||||
ordered = sorted(values)
|
||||
p95_idx = max(0, min(len(ordered) - 1, int((len(ordered) - 1) * 0.95)))
|
||||
class_metrics[key] = {
|
||||
"count": len(ordered),
|
||||
"avg_runtime_ms": round(sum(ordered) / len(ordered), 3),
|
||||
"p95_runtime_ms": ordered[p95_idx],
|
||||
}
|
||||
|
||||
signature_counts: Dict[str, int] = {}
|
||||
for item in errors:
|
||||
signature_counts[item["signature"]] = signature_counts.get(item["signature"], 0) + 1
|
||||
|
||||
metrics = {
|
||||
"total_scenarios": len(scenarios),
|
||||
"ok": len([r for r in results if r["status"] == "ok"]),
|
||||
"error": len([r for r in results if r["status"] == "error"]),
|
||||
"class_metrics": class_metrics,
|
||||
"error_signatures": signature_counts,
|
||||
}
|
||||
(out_dir / "metrics.json").write_text(json.dumps(metrics, indent=2) + "\n", encoding="utf-8")
|
||||
(out_dir / "scenario-results.json").write_text(json.dumps(results, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Knowledge-Conduit Generaltest")
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=["plan", "apply"],
|
||||
default="plan",
|
||||
help="plan: nur Szenarien schreiben; apply: Szenarien materialisieren",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fixtures",
|
||||
default=str(FIXTURES_DIR),
|
||||
help="Verzeichnis mit repo-alpha und repo-beta Fixtures",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
default=str(DEFAULT_OUT_DIR),
|
||||
help="Ausgabeverzeichnis fuer Plan und Artefakte",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
fixtures_dir = Path(args.fixtures)
|
||||
|
||||
out_dir = Path(args.out)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
scenarios = build_scenarios()
|
||||
plan_path = out_dir / "scenario-plan.json"
|
||||
plan_path.write_text(json.dumps([asdict(s) for s in scenarios], indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
metrics = None
|
||||
if args.mode == "apply":
|
||||
metrics = apply_scenarios(scenarios, out_dir, fixtures_dir)
|
||||
|
||||
summary = {
|
||||
"scenario_count": len(scenarios),
|
||||
"mode": args.mode,
|
||||
"fixtures": str(fixtures_dir),
|
||||
"output": str(out_dir),
|
||||
"plan": str(plan_path),
|
||||
"metrics": metrics,
|
||||
}
|
||||
print(json.dumps(summary, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Reference in a new issue