diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..966a061 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI Pipeline + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + validate-and-test: + name: Catalog Validation & Android Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Validate Catalog Graph Integrity + run: python3 scripts/validate_catalog.py + + - name: Audit Editorial Quality + run: python3 scripts/audit_editorial_quality.py + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Run Android Unit Tests + run: | + cd StoryTimeAndroid + gradle test diff --git a/StoryTime2.0/Models/AnthropicClient.swift b/StoryTime2.0/Models/AnthropicClient.swift index 43e41f0..9e16bba 100644 --- a/StoryTime2.0/Models/AnthropicClient.swift +++ b/StoryTime2.0/Models/AnthropicClient.swift @@ -147,28 +147,38 @@ struct AnthropicClient { } /// Pull the first balanced `{...}` block out of a string. Tolerant of - /// minor preamble or trailing prose around the JSON. + /// markdown code block wrappers (```json ... ```), preamble, or trailing prose. static func extractFirstJSONObject(from text: String) -> String? { - guard let start = text.firstIndex(of: "{") else { return nil } + var cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines) + if cleaned.hasPrefix("```json") { + cleaned = String(cleaned.dropFirst(7)) + } else if cleaned.hasPrefix("```") { + cleaned = String(cleaned.dropFirst(3)) + } + if cleaned.hasSuffix("```") { + cleaned = String(cleaned.dropLast(3)) + } + + guard let start = cleaned.firstIndex(of: "{") else { return nil } var depth = 0 var inString = false var escape = false var i = start - while i < text.endIndex { - let c = text[i] - if escape { escape = false; i = text.index(after: i); continue } - if c == "\\" && inString { escape = true; i = text.index(after: i); continue } + while i < cleaned.endIndex { + let c = cleaned[i] + if escape { escape = false; i = cleaned.index(after: i); continue } + if c == "\\" && inString { escape = true; i = cleaned.index(after: i); continue } if c == "\"" { inString.toggle() } else if !inString { if c == "{" { depth += 1 } if c == "}" { depth -= 1 if depth == 0 { - return String(text[start...i]) + return String(cleaned[start...i]) } } } - i = text.index(after: i) + i = cleaned.index(after: i) } return nil } diff --git a/StoryTimeAndroid/app/build.gradle.kts b/StoryTimeAndroid/app/build.gradle.kts index 8d5a7d8..5e6f148 100644 --- a/StoryTimeAndroid/app/build.gradle.kts +++ b/StoryTimeAndroid/app/build.gradle.kts @@ -56,4 +56,6 @@ dependencies { implementation("androidx.work:work-runtime-ktx:2.9.1") debugImplementation("androidx.compose.ui:ui-tooling") + + testImplementation("junit:junit:4.13.2") } diff --git a/StoryTimeAndroid/app/src/main/java/com/storytime/android/ai/AnthropicClient.kt b/StoryTimeAndroid/app/src/main/java/com/storytime/android/ai/AnthropicClient.kt index edbdceb..3af7f48 100644 --- a/StoryTimeAndroid/app/src/main/java/com/storytime/android/ai/AnthropicClient.kt +++ b/StoryTimeAndroid/app/src/main/java/com/storytime/android/ai/AnthropicClient.kt @@ -104,13 +104,23 @@ class AnthropicClient( """.trimIndent() fun extractFirstJsonObject(text: String): String? { - val start = text.indexOf('{') + var cleaned = text.trim() + if (cleaned.startsWith("```json")) { + cleaned = cleaned.removePrefix("```json") + } else if (cleaned.startsWith("```")) { + cleaned = cleaned.removePrefix("```") + } + if (cleaned.endsWith("```")) { + cleaned = cleaned.removeSuffix("```") + } + + val start = cleaned.indexOf('{') if (start < 0) return null var depth = 0 var inString = false var escape = false - for (i in start until text.length) { - val c = text[i] + for (i in start until cleaned.length) { + val c = cleaned[i] if (escape) { escape = false; continue } if (c == '\\' && inString) { escape = true; continue } if (c == '"') { inString = !inString; continue } @@ -118,7 +128,7 @@ class AnthropicClient( if (c == '{') depth++ if (c == '}') { depth-- - if (depth == 0) return text.substring(start, i + 1) + if (depth == 0) return cleaned.substring(start, i + 1) } } return null diff --git a/StoryTimeAndroid/app/src/main/java/com/storytime/android/data/CatalogRepository.kt b/StoryTimeAndroid/app/src/main/java/com/storytime/android/data/CatalogRepository.kt index d8ab928..b86fab0 100644 --- a/StoryTimeAndroid/app/src/main/java/com/storytime/android/data/CatalogRepository.kt +++ b/StoryTimeAndroid/app/src/main/java/com/storytime/android/data/CatalogRepository.kt @@ -8,6 +8,8 @@ import com.storytime.android.model.CatalogStory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import java.io.File +import java.net.URL object CatalogRepository { @@ -17,19 +19,18 @@ object CatalogRepository { } private const val CATALOG_DIR = "Catalog" + private const val REMOTE_DIR = "RemoteCatalog" - /// Parsed bundled index, cached after the first read. The asset file - /// never changes at runtime, so re-reading and re-parsing it on every - /// navigation (each story open went through here) was pure waste. + /// Parsed bundled index, cached after the first read. @Volatile private var cachedBundled: List? = null - /// Parsed bundled stories, cached by asset path. Bundled assets are - /// immutable at runtime, so re-reading and re-decoding a story's JSON on - /// every open (and every saga-continue) was needless IO + parse work. - /// Bounded so we never hold the whole catalog resident. Personal stories - /// are never cached here — they change. + /// Parsed stories, cached by asset/file path. Bounded so we never hold the whole catalog resident. private val storyCache = android.util.LruCache(32) + private fun remoteCatalogDir(context: Context): File { + return File(context.filesDir, REMOTE_DIR).also { if (!it.exists()) it.mkdirs() } + } + private suspend fun bundledIndex(context: Context): List { cachedBundled?.let { return it } return withContext(Dispatchers.IO) { @@ -42,10 +43,28 @@ object CatalogRepository { suspend fun loadIndex(context: Context, includePersonal: Boolean = true): List { val bundled = bundledIndex(context) - if (!includePersonal) return bundled + val remoteIndexFile = File(remoteCatalogDir(context), "index.json") + val remoteEntries = if (remoteIndexFile.exists()) { + runCatching { + withContext(Dispatchers.IO) { + json.decodeFromString(remoteIndexFile.readText()).stories + } + }.getOrDefault(emptyList()) + } else emptyList() + + val mergedMap = LinkedHashMap() + for (entry in bundled) { + mergedMap[entry.id] = entry + } + for (entry in remoteEntries) { + mergedMap[entry.id] = entry + } + val combined = mergedMap.values.toList() + + if (!includePersonal) return combined val personal = runCatching { app.personal.stories.value }.getOrDefault(emptyList()) - if (personal.isEmpty()) return bundled + if (personal.isEmpty()) return combined val personalEntries = personal.map { s -> CatalogIndexEntry( @@ -56,7 +75,7 @@ object CatalogRepository { storyURL = "__personal__/${s.id}.json", ) } - return personalEntries + bundled + return personalEntries + combined } suspend fun loadStory(context: Context, entry: CatalogIndexEntry): CatalogStory = withContext(Dispatchers.IO) { @@ -66,7 +85,13 @@ object CatalogRepository { } val file = entry.storyURL ?: "${entry.id}.json" storyCache.get(file)?.let { return@withContext it } - val text = context.assets.open("$CATALOG_DIR/$file").bufferedReader().use { it.readText() } + + val remoteFile = File(remoteCatalogDir(context), file) + val text = if (remoteFile.exists()) { + remoteFile.readText() + } else { + context.assets.open("$CATALOG_DIR/$file").bufferedReader().use { it.readText() } + } json.decodeFromString(text).also { storyCache.put(file, it) } } @@ -76,4 +101,30 @@ object CatalogRepository { val entry = index.firstOrNull { it.id == id } ?: return@withContext null loadStory(context, entry) } + + suspend fun refresh(context: Context, baseUrl: String): Result = withContext(Dispatchers.IO) { + runCatching { + val cleanBase = baseUrl.trimEnd('/') + val indexUrl = "$cleanBase/index.json" + val indexText = URL(indexUrl).readText() + val remoteIndex = json.decodeFromString(indexText) + + val dir = remoteCatalogDir(context) + File(dir, "index.json").writeText(indexText) + + var fetchedCount = 0 + for (entry in remoteIndex.stories) { + val file = entry.storyURL ?: "${entry.id}.json" + val localRemoteFile = File(dir, file) + val storyUrl = "$cleanBase/$file" + val storyText = URL(storyUrl).readText() + if (!localRemoteFile.exists() || localRemoteFile.readText() != storyText) { + localRemoteFile.writeText(storyText) + storyCache.remove(file) + fetchedCount++ + } + } + fetchedCount + } + } } diff --git a/StoryTimeAndroid/app/src/test/java/com/storytime/android/ai/AnthropicClientTest.kt b/StoryTimeAndroid/app/src/test/java/com/storytime/android/ai/AnthropicClientTest.kt new file mode 100644 index 0000000..6ae4693 --- /dev/null +++ b/StoryTimeAndroid/app/src/test/java/com/storytime/android/ai/AnthropicClientTest.kt @@ -0,0 +1,57 @@ +package com.storytime.android.ai + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class AnthropicClientTest { + + @Test + fun testExtractFirstJsonObjectPlain() { + val input = """{"id": "test-1", "title": "Sample"}""" + val extracted = AnthropicClient.extractFirstJsonObject(input) + assertNotNull(extracted) + assertEquals(input, extracted) + } + + @Test + fun testExtractFirstJsonObjectMarkdownJson() { + val input = """ + ```json + { + "id": "user-test-1234", + "title": "Markdown Title" + } + ``` + """.trimIndent() + val extracted = AnthropicClient.extractFirstJsonObject(input) + assertNotNull(extracted) + val expected = """ + { + "id": "user-test-1234", + "title": "Markdown Title" + } + """.trimIndent() + assertEquals(expected, extracted) + } + + @Test + fun testExtractFirstJsonObjectWithPreambleAndPostamble() { + val input = """ + Here is your requested story JSON: + {"id": "sample", "title": "Preamble Test"} + Hope you enjoy reading it! + """.trimIndent() + val extracted = AnthropicClient.extractFirstJsonObject(input) + assertNotNull(extracted) + assertEquals("""{"id": "sample", "title": "Preamble Test"}""", extracted) + } + + @Test + fun testExtractFirstJsonObjectNoJson() { + val input = "This string contains no JSON object at all." + val extracted = AnthropicClient.extractFirstJsonObject(input) + assertNull(extracted) + } +} diff --git a/scripts/build_stories.py b/scripts/build_stories.py index 92c4ece..b1ce284 100644 --- a/scripts/build_stories.py +++ b/scripts/build_stories.py @@ -31,10 +31,12 @@ """ import json import hashlib +import shutil import sys from pathlib import Path CATALOG = Path(__file__).resolve().parent.parent / "StoryTime2.0" / "Resources" / "Catalog" +ANDROID_CATALOG = Path(__file__).resolve().parent.parent / "StoryTimeAndroid" / "app" / "src" / "main" / "assets" / "Catalog" def polished_consequence(raw, choice, story_id, scene_id, choice_index): @@ -56,12 +58,12 @@ def polished_consequence(raw, choice, story_id, scene_id, choice_index): variant = int(hashlib.sha256(key).hexdigest()[:8], 16) % 6 safe_choice = choice.strip().rstrip(".!?") beats = [ - f"You commit to “{safe_choice}.” The story records the instinct as “{label}” and carries it forward.", - f"“{safe_choice}” closes one possibility and opens another. The moment leaves a clear note behind: {label}.", - f"The decision settles the question for now. Its verdict—{label}—follows you into whatever comes next.", - f"There is no taking “{safe_choice}” back. {label} shapes the silence before the next moment arrives.", - f"You let the choice stand. The path shifts almost imperceptibly, marked by one lasting impulse: {label}.", - f"The choice lands, and the scene changes around it. If it has a name, that name is {label}.", + f"Stepping into “{safe_choice}” sets a firm direction. The impression left behind is clear: {label}.", + f"Taking “{safe_choice}” alters your standing immediately. This moment bears the distinct mark of {label}.", + f"Resolving on “{safe_choice}” shifts your position. The outcome reflects a single driving force: {label}.", + f"Acting on “{safe_choice}” carries immediate weight. The impulse behind it is unmistakable: {label}.", + f"Following through on “{safe_choice}” opens immediate consequences. It signals a defining trait: {label}.", + f"Embracing “{safe_choice}” changes the immediate atmosphere. The action carries the unmistakable imprint of {label}.", ] return beats[variant] @@ -231,6 +233,12 @@ def build(specs, check=False, preserve_copy=True): index_path.write_text(json.dumps(index, indent=2, ensure_ascii=False) + "\n") print(f" updated index.json: {len(entries)} entries") + if ANDROID_CATALOG.parent.exists(): + ANDROID_CATALOG.mkdir(parents=True, exist_ok=True) + for src_file in CATALOG.glob("*.json"): + shutil.copy2(src_file, ANDROID_CATALOG / src_file.name) + print(f" synced catalog to Android assets: {ANDROID_CATALOG}") + if __name__ == "__main__": from stories_data import SPECS