Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
26 changes: 18 additions & 8 deletions StoryTime2.0/Models/AnthropicClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions StoryTimeAndroid/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,21 +104,31 @@ 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 }
if (inString) continue
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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<CatalogIndexEntry>? = 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<String, CatalogStory>(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<CatalogIndexEntry> {
cachedBundled?.let { return it }
return withContext(Dispatchers.IO) {
Expand All @@ -42,10 +43,28 @@ object CatalogRepository {

suspend fun loadIndex(context: Context, includePersonal: Boolean = true): List<CatalogIndexEntry> {
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<CatalogIndex>(remoteIndexFile.readText()).stories
}
}.getOrDefault(emptyList())
} else emptyList()

val mergedMap = LinkedHashMap<String, CatalogIndexEntry>()
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(
Expand All @@ -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) {
Expand All @@ -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<CatalogStory>(text).also { storyCache.put(file, it) }
}

Expand All @@ -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<Int> = withContext(Dispatchers.IO) {
runCatching {
val cleanBase = baseUrl.trimEnd('/')
val indexUrl = "$cleanBase/index.json"
val indexText = URL(indexUrl).readText()
val remoteIndex = json.decodeFromString<CatalogIndex>(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
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
20 changes: 14 additions & 6 deletions scripts/build_stories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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]

Expand Down Expand Up @@ -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
Expand Down
Loading