Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/ailey-prmil_expense-tracking-mobile-app.json

1 line
36 KiB
JSON

{"repo": "ailey-prmil/expense-tracking-mobile-app", "files": {"app/src/main/java/mobile/com/expense_tracking_app/ExpenseTrackingApplication.kt": {"001febfc67b7ec8365997bdc9ee30c19e01b0575": {"t": 1785745716.3345652, "n": 6890, "h": "0a541a105bfb2894", "c": "package mobile.com.expense_tracking_app\n\nimport android.app.Application\nimport android.content.Context\nimport android.util.Log\nimport mobile.com.expense_tracking_app.core.ai.AiContextBuilder\nimport mobile.com.expense_tracking_app.core.ai.AiPreferences\nimport mobile.com.expense_tracking_app.core.ai.AiRepository\nimport mobile.com.expense_tracking_app.core.ai.LlmProviderRegistry\nimport mobile.com.expense_tracking_app.core.ai.ProviderId\nimport mobile.com.expense_tracking_app.core.ai.ToolExecutor\nimport mobile.com.expense_tracking_app.core.data.AppDatabase\nimport mobile.com.expense_tracking_app.core.data.preferences.UserPreferences\nimport mobile.com.expense_tracking_app.core.data.repositories.BudgetLogRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.CategoryRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.SubscriptionRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.TransactionRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.TransactionTemplateRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.TransferRepository\nimport mobile.com.expense_tracking_app.core.ocr.LlmReceiptParser\nimport mobile.com.expense_tracking_app.core.ocr.MlKitOcrEngine\nimport mobile.com.expense_tracking_app.core.ocr.OcrEngine\nimport mobile.com.expense_tracking_app.core.ocr.ReceiptParser\nimport mobile.com.expense_tracking_app.core.workers.BudgetArchiveWorker\nimport mobile.com.expense_tracking_app.feature_widget.WidgetRefresher\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.SupervisorJob\nimport kotlinx.coroutines.launch\nimport okhttp3.OkHttpClient\nimport java.util.concurrent.TimeUnit\n\n\nclass ExpenseTrackingApplication : Application() {\n\n val database by lazy { AppDatabase.getDatabase(this) }\n\n val transactionRepository by lazy { TransactionRepository(database.transactionDao()) }\n val categoryRepository by lazy { CategoryRepository(database.categoryDao()) }\n val transferRepository by lazy { TransferRepository(database.transferDao()) }\n val subscriptionRepository by lazy { SubscriptionRepository(database.subscriptionDao()) }\n val budgetLogRepository by lazy { BudgetLogRepository(database.budgetLogDao()) }\n val transactionTemplateRepository by lazy {\n TransactionTemplateRepository(database.transactionTemplateDao())\n }\n val userPreferences by lazy { UserPreferences(this) }\n\n // ── Unified AI configuration (chat + receipt) ──────────────────────────\n val aiPreferences by lazy { AiPreferences(this) }\n val aiProviderRegistry by lazy { LlmProviderRegistry(this) }\n\n // ── Receipt scanning (OCR + LLM) ──────────────────────────────────────\n private val httpClient by lazy {\n OkHttpClient.Builder()\n .callTimeout(30, TimeUnit.SECONDS)\n .build()\n }\n\n val ocrEngine: OcrEngine by lazy { MlKitOcrEngine() }\n\n /**\n * Returns a configured [ReceiptParser] iff the user has set a receipt provider\n * with a non-blank API key. The caller (typically `feature_expense`) treats\n * `null` as \"receipt feature gated; prompt the user to set up an AI key\".\n *\n * `OPENAI_COMPAT` derives its endpoint from the user-supplied base URL; all\n * other supported receipt providers have a fixed endpoint registered in\n * [LlmProviderRegistry.openAiCompatibleEndpoint].\n */\n fun receiptParserOrNull(): ReceiptParser? {\n val s = aiPreferences.settings.value\n val pid = s.receiptProviderId ?: return null\n val key = s.perProviderKey[pid].orEmpty()\n if (key.isBlank()) return null\n val endpoint = aiProviderRegistry.openAiCompatibleEndpoint[pid]\n ?: deriveCompatEndpoint(pid, s.openAiCompatBaseUrl)\n ?: return null\n val model = s.receiptModelFor(pid, aiProviderRegistry.defaultModel(pid))\n return LlmReceiptParser(\n client = httpClient,\n apiKey = key,\n endpoint = endpoint,\n model = model,\n )\n }\n\n private fun deriveCompatEndpoint(pid: ProviderId, baseUrl: String?): String? {\n if (pid != ProviderId.OPENAI_COMPAT) return null\n val base = baseUrl?.trim()?.takeIf { it.isNotBlank() } ?: return null\n return base.trimEnd('/') + \"/chat/completions\"\n }\n\n val aiRepository: AiRepository by lazy {\n AiRepository(\n appContext = this,\n prefs = aiPreferences,\n toolExecutor = ToolExecutor(\n appContext = this,\n transactionRepo = transactionRepository,\n categoryRepo = categoryRepository,\n subscriptionRepo = subscriptionRepository,\n ),\n contextBuilder = AiContextBuilder(\n transactionRepo = transactionRepository,\n categoryRepo = categoryRepository,\n subscriptionRepo = subscriptionRepository,\n ),\n registry = aiProviderRegistry,\n userPreferences = userPreferences,\n )\n }\n\n private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)\n\n override fun onCreate() {\n super.onCreate()\n BudgetArchiveWorker.enqueue(this)\n // Refresh the Quick-Add widget's today-total in case any transactions landed\n // while the app process was dead.\n WidgetRefresher.refreshAll(this)\n appScope.launch {\n migrateBuildConfigKeyIfNeeded()\n }\n }\n\n /**\n * One-time migration: if the developer's debug-build key is still baked into\n * `BuildConfig.DEEPSEEK_API_KEY` and the user has never set up a receipt\n * provider, seed the DeepSeek slot so the receipt feature works on first\n * launch without requiring manual entry. Runs at most once per install.\n */\n private fun migrateBuildConfigKeyIfNeeded() {\n val flagPrefs = getSharedPreferences(MIGRATION_PREFS, Context.MODE_PRIVATE)\n if (flagPrefs.getBoolean(MIGRATION_DONE, false)) return\n\n val seedKey = BuildConfig.DEEPSEEK_API_KEY\n val settings = aiPreferences.settings.value\n val deepSeekKeyBlank = settings.perProviderKey[ProviderId.DEEPSEEK].isNullOrBlank()\n if (seedKey.isNotBlank() && deepSeekKeyBlank) {\n aiPreferences.setApiKey(ProviderId.DEEPSEEK, seedKey)\n if (settings.receiptProviderId == null) {\n aiPreferences.setReceiptProvider(ProviderId.DEEPSEEK)\n }\n Log.i(TAG, \"Seeded DeepSeek key from BuildConfig (first-run migration)\")\n }\n flagPrefs.edit().putBoolean(MIGRATION_DONE, true).apply()\n }\n\n companion object {\n private const val TAG = \"ExpenseTrackingApp\"\n private const val MIGRATION_PREFS = \"llm_migration\"\n private const val MIGRATION_DONE = \"buildconfig_seeded_v1\"\n }\n}\n"}, "4d1f5a4dd6c0571261372d5a879d0cd8866f8481": {"t": 1785755886.925853, "n": 6574, "h": "ade58a8491e1ba17", "c": "package mobile.com.expense_tracking_app\n\nimport android.app.Application\nimport android.content.Context\nimport android.util.Log\nimport mobile.com.expense_tracking_app.core.ai.AiContextBuilder\nimport mobile.com.expense_tracking_app.core.ai.AiPreferences\nimport mobile.com.expense_tracking_app.core.ai.AiRepository\nimport mobile.com.expense_tracking_app.core.ai.LlmProviderRegistry\nimport mobile.com.expense_tracking_app.core.ai.ProviderId\nimport mobile.com.expense_tracking_app.core.ai.ToolExecutor\nimport mobile.com.expense_tracking_app.core.data.AppDatabase\nimport mobile.com.expense_tracking_app.core.data.preferences.UserPreferences\nimport mobile.com.expense_tracking_app.core.data.repositories.BudgetLogRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.CategoryRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.SubscriptionRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.TransactionRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.TransferRepository\nimport mobile.com.expense_tracking_app.core.demo.DemoReceiptSeeder\nimport mobile.com.expense_tracking_app.core.ocr.LlmReceiptParser\nimport mobile.com.expense_tracking_app.core.ocr.MlKitOcrEngine\nimport mobile.com.expense_tracking_app.core.ocr.OcrEngine\nimport mobile.com.expense_tracking_app.core.ocr.ReceiptParser\nimport mobile.com.expense_tracking_app.core.workers.BudgetArchiveWorker\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.SupervisorJob\nimport kotlinx.coroutines.launch\nimport okhttp3.OkHttpClient\nimport java.util.concurrent.TimeUnit\n\n\nclass ExpenseTrackingApplication : Application() {\n\n val database by lazy { AppDatabase.getDatabase(this) }\n\n val transactionRepository by lazy { TransactionRepository(database.transactionDao()) }\n val categoryRepository by lazy { CategoryRepository(database.categoryDao()) }\n val transferRepository by lazy { TransferRepository(database.transferDao()) }\n val subscriptionRepository by lazy { SubscriptionRepository(database.subscriptionDao()) }\n val budgetLogRepository by lazy { BudgetLogRepository(database.budgetLogDao()) }\n val userPreferences by lazy { UserPreferences(this) }\n\n // ── Unified AI configuration (chat + receipt) ──────────────────────────\n val aiPreferences by lazy { AiPreferences(this) }\n val aiProviderRegistry by lazy { LlmProviderRegistry(this) }\n\n // ── Receipt scanning (OCR + LLM) ──────────────────────────────────────\n private val httpClient by lazy {\n OkHttpClient.Builder()\n .callTimeout(30, TimeUnit.SECONDS)\n .build()\n }\n\n val ocrEngine: OcrEngine by lazy { MlKitOcrEngine() }\n\n /**\n * Returns a configured [ReceiptParser] iff the user has set a receipt provider\n * with a non-blank API key. The caller (typically `feature_expense`) treats\n * `null` as \"receipt feature gated; prompt the user to set up an AI key\".\n *\n * `OPENAI_COMPAT` derives its endpoint from the user-supplied base URL; all\n * other supported receipt providers have a fixed endpoint registered in\n * [LlmProviderRegistry.openAiCompatibleEndpoint].\n */\n fun receiptParserOrNull(): ReceiptParser? {\n val s = aiPreferences.settings.value\n val pid = s.receiptProviderId ?: return null\n val key = s.perProviderKey[pid].orEmpty()\n if (key.isBlank()) return null\n val endpoint = aiProviderRegistry.openAiCompatibleEndpoint[pid]\n ?: deriveCompatEndpoint(pid, s.openAiCompatBaseUrl)\n ?: return null\n val model = s.receiptModelFor(pid, aiProviderRegistry.defaultModel(pid))\n return LlmReceiptParser(\n client = httpClient,\n apiKey = key,\n endpoint = endpoint,\n model = model,\n )\n }\n\n private fun deriveCompatEndpoint(pid: ProviderId, baseUrl: String?): String? {\n if (pid != ProviderId.OPENAI_COMPAT) return null\n val base = baseUrl?.trim()?.takeIf { it.isNotBlank() } ?: return null\n return base.trimEnd('/') + \"/chat/completions\"\n }\n\n val aiRepository: AiRepository by lazy {\n AiRepository(\n appContext = this,\n prefs = aiPreferences,\n toolExecutor = ToolExecutor(\n appContext = this,\n transactionRepo = transactionRepository,\n categoryRepo = categoryRepository,\n subscriptionRepo = subscriptionRepository,\n ),\n contextBuilder = AiContextBuilder(\n transactionRepo = transactionRepository,\n categoryRepo = categoryRepository,\n subscriptionRepo = subscriptionRepository,\n ),\n registry = aiProviderRegistry,\n userPreferences = userPreferences,\n )\n }\n\n private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)\n\n override fun onCreate() {\n super.onCreate()\n BudgetArchiveWorker.enqueue(this)\n appScope.launch {\n migrateBuildConfigKeyIfNeeded()\n DemoReceiptSeeder.seedIfNeeded(this@ExpenseTrackingApplication)\n }\n }\n\n /**\n * One-time migration: if the developer's debug-build key is still baked into\n * `BuildConfig.DEEPSEEK_API_KEY` and the user has never set up a receipt\n * provider, seed the DeepSeek slot so the receipt feature works on first\n * launch without requiring manual entry. Runs at most once per install.\n */\n private fun migrateBuildConfigKeyIfNeeded() {\n val flagPrefs = getSharedPreferences(MIGRATION_PREFS, Context.MODE_PRIVATE)\n if (flagPrefs.getBoolean(MIGRATION_DONE, false)) return\n\n val seedKey = BuildConfig.DEEPSEEK_API_KEY\n val settings = aiPreferences.settings.value\n val deepSeekKeyBlank = settings.perProviderKey[ProviderId.DEEPSEEK].isNullOrBlank()\n if (seedKey.isNotBlank() && deepSeekKeyBlank) {\n aiPreferences.setApiKey(ProviderId.DEEPSEEK, seedKey)\n if (settings.receiptProviderId == null) {\n aiPreferences.setReceiptProvider(ProviderId.DEEPSEEK)\n }\n Log.i(TAG, \"Seeded DeepSeek key from BuildConfig (first-run migration)\")\n }\n flagPrefs.edit().putBoolean(MIGRATION_DONE, true).apply()\n }\n\n companion object {\n private const val TAG = \"ExpenseTrackingApp\"\n private const val MIGRATION_PREFS = \"llm_migration\"\n private const val MIGRATION_DONE = \"buildconfig_seeded_v1\"\n }\n}\n"}, "bf73f299a1aba7f74e95b26d44dfad52dfcbde6c": {"t": 1785755888.4234982, "n": 2640, "h": "1e8c3a285cbf5290", "c": "package mobile.com.expense_tracking_app\n\nimport android.app.Application\nimport mobile.com.expense_tracking_app.core.ai.AiContextBuilder\nimport mobile.com.expense_tracking_app.core.ai.AiPreferences\nimport mobile.com.expense_tracking_app.core.ai.AiRepository\nimport mobile.com.expense_tracking_app.core.ai.LlmProviderRegistry\nimport mobile.com.expense_tracking_app.core.ai.ToolExecutor\nimport mobile.com.expense_tracking_app.core.data.AppDatabase\nimport mobile.com.expense_tracking_app.core.data.preferences.UserPreferences\nimport mobile.com.expense_tracking_app.core.data.repositories.BudgetLogRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.CategoryRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.SubscriptionRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.TransactionRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.TransferRepository\nimport mobile.com.expense_tracking_app.core.workers.BudgetArchiveWorker\n\n\nclass ExpenseTrackingApplication : Application() {\n\n val database by lazy { AppDatabase.getDatabase(this) }\n\n val transactionRepository by lazy { TransactionRepository(database.transactionDao()) }\n val categoryRepository by lazy { CategoryRepository(database.categoryDao()) }\n val transferRepository by lazy { TransferRepository(database.transferDao()) }\n val subscriptionRepository by lazy { SubscriptionRepository(database.subscriptionDao()) }\n val budgetLogRepository by lazy { BudgetLogRepository(database.budgetLogDao()) }\n val userPreferences by lazy { UserPreferences(this) }\n\n // ── AI ─────────────────────────────────────────────────────────────\n val aiPreferences by lazy { AiPreferences(this) }\n val aiProviderRegistry by lazy { LlmProviderRegistry(this) }\n val aiRepository: AiRepository by lazy {\n AiRepository(\n appContext = this,\n prefs = aiPreferences,\n toolExecutor = ToolExecutor(\n appContext = this,\n transactionRepo = transactionRepository,\n categoryRepo = categoryRepository,\n subscriptionRepo = subscriptionRepository,\n ),\n contextBuilder = AiContextBuilder(\n transactionRepo = transactionRepository,\n categoryRepo = categoryRepository,\n subscriptionRepo = subscriptionRepository,\n ),\n registry = aiProviderRegistry,\n userPreferences = userPreferences,\n )\n }\n\n override fun onCreate() {\n super.onCreate()\n BudgetArchiveWorker.enqueue(this)\n }\n}\n"}, "32bef4ac43b22268904a81da16853e38b094457c": {"t": 1785755889.8185065, "n": 1406, "h": "61e4960b3b91fbe4", "c": "package mobile.com.expense_tracking_app\n\nimport android.app.Application\nimport mobile.com.expense_tracking_app.core.data.AppDatabase\nimport mobile.com.expense_tracking_app.core.data.preferences.UserPreferences\nimport mobile.com.expense_tracking_app.core.data.repositories.CategoryRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.TransactionRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.TransferRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.SubscriptionRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.BudgetLogRepository\nimport mobile.com.expense_tracking_app.core.workers.BudgetArchiveWorker\n\n\nclass ExpenseTrackingApplication : Application() {\n\n val database by lazy { AppDatabase.getDatabase(this) }\n\n val transactionRepository by lazy { TransactionRepository(database.transactionDao()) }\n val categoryRepository by lazy { CategoryRepository(database.categoryDao()) }\n val transferRepository by lazy { TransferRepository(database.transferDao()) }\n val subscriptionRepository by lazy { SubscriptionRepository(database.subscriptionDao()) }\n val budgetLogRepository by lazy { BudgetLogRepository(database.budgetLogDao()) }\n val userPreferences by lazy { UserPreferences(this) }\n\n override fun onCreate() {\n super.onCreate()\n BudgetArchiveWorker.enqueue(this)\n }\n}"}, "c8cbfe1e734652398d8e9582fe4dc9064efdceab": {"t": 1785755891.1581848, "n": 1271, "h": "504dae03b6204c47", "c": "package mobile.com.expense_tracking_app\n\nimport android.app.Application\nimport mobile.com.expense_tracking_app.core.data.AppDatabase\nimport mobile.com.expense_tracking_app.core.data.repositories.CategoryRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.TransactionRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.TransferRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.SubscriptionRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.BudgetLogRepository\nimport mobile.com.expense_tracking_app.core.workers.BudgetArchiveWorker\n\n\nclass ExpenseTrackingApplication : Application() {\n\n val database by lazy { AppDatabase.getDatabase(this) }\n\n val transactionRepository by lazy { TransactionRepository(database.transactionDao()) }\n val categoryRepository by lazy { CategoryRepository(database.categoryDao()) }\n val transferRepository by lazy { TransferRepository(database.transferDao()) }\n val subscriptionRepository by lazy { SubscriptionRepository(database.subscriptionDao()) }\n val budgetLogRepository by lazy { BudgetLogRepository(database.budgetLogDao()) }\n\n override fun onCreate() {\n super.onCreate()\n BudgetArchiveWorker.enqueue(this)\n }\n}"}, "dddbf5be3522e5ca7a8a5db8390498411820d48c": {"t": 1785755892.7123497, "n": 1305, "h": "e87f2dd17a23026d", "c": "package mobile.com.expense_tracking_app\n\nimport android.app.Application\nimport mobile.com.expense_tracking_app.core.data.AppDatabase\nimport mobile.com.expense_tracking_app.core.data.repositories.CategoryRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.TransactionRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.TransferRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.SubscriptionRepository\nimport mobile.com.expense_tracking_app.core.data.repositories.BudgetLogRepository\n\n\nclass ExpenseTrackingApplication : Application() {\n\n // 'by lazy' means the database is only physically created in memory\n // the exact moment a screen asks for it for the first time.\n val database by lazy { AppDatabase.getDatabase(this) }\n\n // Initialize your repositories here so they are available globally\n val transactionRepository by lazy { TransactionRepository(database.transactionDao()) }\n val categoryRepository by lazy { CategoryRepository(database.categoryDao()) }\n val transferRepository by lazy { TransferRepository(database.transferDao()) }\n val subscriptionRepository by lazy { SubscriptionRepository(database.subscriptionDao()) }\n val budgetLogRepository by lazy { BudgetLogRepository(database.budgetLogDao()) }\n}"}}, "app/src/main/java/mobile/com/expense_tracking_app/core/ocr/LlmReceiptParser.kt": {"001febfc67b7ec8365997bdc9ee30c19e01b0575": {"t": 1785745731.3812222, "n": 6955, "h": "90cec3adcafd3190", "c": "package mobile.com.expense_tracking_app.core.ocr\n\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.withContext\nimport okhttp3.MediaType.Companion.toMediaType\nimport okhttp3.OkHttpClient\nimport okhttp3.Request\nimport okhttp3.RequestBody.Companion.toRequestBody\nimport org.json.JSONArray\nimport org.json.JSONObject\nimport java.io.IOException\nimport java.time.LocalDate\nimport java.time.ZoneId\n\nclass LlmReceiptParser(\n private val client: OkHttpClient,\n private val apiKey: String,\n private val endpoint: String,\n private val model: String,\n) : ReceiptParser {\n\n override suspend fun parse(\n rawText: String,\n categories: List<CategoryHint>,\n ): ParseResult = withContext(Dispatchers.IO) {\n try {\n if (apiKey.isBlank()) {\n return@withContext ParseResult.Failure(\n IllegalStateException(\"DEEPSEEK_API_KEY is not configured\")\n )\n }\n\n val body = buildRequestBody(rawText, categories)\n val request = Request.Builder()\n .url(endpoint)\n .header(\"Authorization\", \"Bearer $apiKey\")\n .header(\"Content-Type\", \"application/json\")\n .post(body.toString().toRequestBody(JSON_MEDIA_TYPE))\n .build()\n\n client.newCall(request).execute().use { response ->\n val responseBody = response.body?.string().orEmpty()\n if (!response.isSuccessful) {\n return@withContext ParseResult.Failure(\n IOException(\"HTTP ${response.code}: $responseBody\")\n )\n }\n val parsed = parseApiResponse(responseBody)\n ParseResult.Success(filterCategoryIds(parsed, categories))\n }\n } catch (t: Throwable) {\n ParseResult.Failure(t)\n }\n }\n\n private fun buildRequestBody(rawText: String, categories: List<CategoryHint>): JSONObject {\n val categoriesJson = JSONArray().apply {\n categories.forEach { c ->\n put(JSONObject().apply {\n put(\"id\", c.id)\n put(\"name\", c.name)\n })\n }\n }\n val userContent = buildString {\n append(\"Available categories (use these category_ids when classifying items):\\n\")\n append(categoriesJson.toString())\n append(\"\\n\\nReceipt OCR text:\\n\")\n append(rawText)\n }\n val messages = JSONArray().apply {\n put(\n JSONObject().apply {\n put(\"role\", \"system\")\n put(\"content\", RECEIPT_SYSTEM_PROMPT)\n }\n )\n put(\n JSONObject().apply {\n put(\"role\", \"user\")\n put(\"content\", userContent)\n }\n )\n }\n return JSONObject().apply {\n put(\"model\", model)\n put(\"messages\", messages)\n put(\"response_format\", JSONObject().put(\"type\", \"json_object\"))\n put(\"temperature\", 0)\n put(\"max_tokens\", 1024)\n }\n }\n\n private fun filterCategoryIds(\n parsed: ParsedReceipt,\n categories: List<CategoryHint>,\n ): ParsedReceipt {\n val allowed = categories.mapTo(HashSet()) { it.id }\n val sanitized = parsed.items.map { item ->\n if (item.categoryId != null && item.categoryId !in allowed) {\n item.copy(categoryId = null)\n } else {\n item\n }\n }\n return parsed.copy(items = sanitized)\n }\n\n companion object {\n private val JSON_MEDIA_TYPE = \"application/json\".toMediaType()\n\n /**\n * Maps a DeepSeek/OpenAI-style chat-completions response body into a [ParsedReceipt].\n * Throws on malformed input. Public for unit testing.\n */\n fun parseApiResponse(rawApiJson: String): ParsedReceipt {\n val outer = JSONObject(rawApiJson)\n val choices = outer.getJSONArray(\"choices\")\n if (choices.length() == 0) {\n throw IllegalArgumentException(\"response has no choices\")\n }\n val content = choices.getJSONObject(0)\n .getJSONObject(\"message\")\n .getString(\"content\")\n val payload = JSONObject(content)\n\n val dateMillis = extractDateMillis(payload)\n val items = extractItems(payload)\n val total = extractTotal(payload)\n\n return ParsedReceipt(\n dateMillis = dateMillis,\n items = items,\n detectedTotal = total,\n )\n }\n\n private fun extractDateMillis(payload: JSONObject): Long {\n val raw = payload.optString(\"date_iso\", \"\")\n return try {\n LocalDate.parse(raw)\n .atStartOfDay(ZoneId.systemDefault())\n .toInstant()\n .toEpochMilli()\n } catch (_: Throwable) {\n LocalDate.now()\n .atStartOfDay(ZoneId.systemDefault())\n .toInstant()\n .toEpochMilli()\n }\n }\n\n private fun extractItems(payload: JSONObject): List<ParsedItem> {\n val arr = payload.optJSONArray(\"items\") ?: return emptyList()\n val out = mutableListOf<ParsedItem>()\n for (i in 0 until arr.length()) {\n val obj = arr.optJSONObject(i) ?: continue\n val name = obj.optString(\"name\", \"\").trim()\n if (name.isEmpty()) continue\n val amount = obj.opt(\"amount\")\n val amountDouble = when (amount) {\n is Number -> amount.toDouble()\n is String -> amount.toDoubleOrNull() ?: continue\n else -> continue\n }\n if (amountDouble <= 0.0 || amountDouble.isNaN()) continue\n val categoryId = extractCategoryId(obj)\n out += ParsedItem(name = name, amount = amountDouble, categoryId = categoryId)\n }\n return out\n }\n\n private fun extractCategoryId(itemObj: JSONObject): Int? {\n if (!itemObj.has(\"category_id\") || itemObj.isNull(\"category_id\")) return null\n return when (val v = itemObj.opt(\"category_id\")) {\n is Number -> v.toInt()\n is String -> v.toIntOrNull()\n else -> null\n }\n }\n\n private fun extractTotal(payload: JSONObject): Double? {\n if (!payload.has(\"total\") || payload.isNull(\"total\")) return null\n return when (val v = payload.opt(\"total\")) {\n is Number -> v.toDouble().takeUnless { it.isNaN() }\n is String -> v.toDoubleOrNull()\n else -> null\n }\n }\n }\n}\n"}, "4d1f5a4dd6c0571261372d5a879d0cd8866f8481": {"t": 1785755886.598013, "n": 6955, "h": "90cec3adcafd3190", "c": "package mobile.com.expense_tracking_app.core.ocr\n\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.withContext\nimport okhttp3.MediaType.Companion.toMediaType\nimport okhttp3.OkHttpClient\nimport okhttp3.Request\nimport okhttp3.RequestBody.Companion.toRequestBody\nimport org.json.JSONArray\nimport org.json.JSONObject\nimport java.io.IOException\nimport java.time.LocalDate\nimport java.time.ZoneId\n\nclass LlmReceiptParser(\n private val client: OkHttpClient,\n private val apiKey: String,\n private val endpoint: String,\n private val model: String,\n) : ReceiptParser {\n\n override suspend fun parse(\n rawText: String,\n categories: List<CategoryHint>,\n ): ParseResult = withContext(Dispatchers.IO) {\n try {\n if (apiKey.isBlank()) {\n return@withContext ParseResult.Failure(\n IllegalStateException(\"DEEPSEEK_API_KEY is not configured\")\n )\n }\n\n val body = buildRequestBody(rawText, categories)\n val request = Request.Builder()\n .url(endpoint)\n .header(\"Authorization\", \"Bearer $apiKey\")\n .header(\"Content-Type\", \"application/json\")\n .post(body.toString().toRequestBody(JSON_MEDIA_TYPE))\n .build()\n\n client.newCall(request).execute().use { response ->\n val responseBody = response.body?.string().orEmpty()\n if (!response.isSuccessful) {\n return@withContext ParseResult.Failure(\n IOException(\"HTTP ${response.code}: $responseBody\")\n )\n }\n val parsed = parseApiResponse(responseBody)\n ParseResult.Success(filterCategoryIds(parsed, categories))\n }\n } catch (t: Throwable) {\n ParseResult.Failure(t)\n }\n }\n\n private fun buildRequestBody(rawText: String, categories: List<CategoryHint>): JSONObject {\n val categoriesJson = JSONArray().apply {\n categories.forEach { c ->\n put(JSONObject().apply {\n put(\"id\", c.id)\n put(\"name\", c.name)\n })\n }\n }\n val userContent = buildString {\n append(\"Available categories (use these category_ids when classifying items):\\n\")\n append(categoriesJson.toString())\n append(\"\\n\\nReceipt OCR text:\\n\")\n append(rawText)\n }\n val messages = JSONArray().apply {\n put(\n JSONObject().apply {\n put(\"role\", \"system\")\n put(\"content\", RECEIPT_SYSTEM_PROMPT)\n }\n )\n put(\n JSONObject().apply {\n put(\"role\", \"user\")\n put(\"content\", userContent)\n }\n )\n }\n return JSONObject().apply {\n put(\"model\", model)\n put(\"messages\", messages)\n put(\"response_format\", JSONObject().put(\"type\", \"json_object\"))\n put(\"temperature\", 0)\n put(\"max_tokens\", 1024)\n }\n }\n\n private fun filterCategoryIds(\n parsed: ParsedReceipt,\n categories: List<CategoryHint>,\n ): ParsedReceipt {\n val allowed = categories.mapTo(HashSet()) { it.id }\n val sanitized = parsed.items.map { item ->\n if (item.categoryId != null && item.categoryId !in allowed) {\n item.copy(categoryId = null)\n } else {\n item\n }\n }\n return parsed.copy(items = sanitized)\n }\n\n companion object {\n private val JSON_MEDIA_TYPE = \"application/json\".toMediaType()\n\n /**\n * Maps a DeepSeek/OpenAI-style chat-completions response body into a [ParsedReceipt].\n * Throws on malformed input. Public for unit testing.\n */\n fun parseApiResponse(rawApiJson: String): ParsedReceipt {\n val outer = JSONObject(rawApiJson)\n val choices = outer.getJSONArray(\"choices\")\n if (choices.length() == 0) {\n throw IllegalArgumentException(\"response has no choices\")\n }\n val content = choices.getJSONObject(0)\n .getJSONObject(\"message\")\n .getString(\"content\")\n val payload = JSONObject(content)\n\n val dateMillis = extractDateMillis(payload)\n val items = extractItems(payload)\n val total = extractTotal(payload)\n\n return ParsedReceipt(\n dateMillis = dateMillis,\n items = items,\n detectedTotal = total,\n )\n }\n\n private fun extractDateMillis(payload: JSONObject): Long {\n val raw = payload.optString(\"date_iso\", \"\")\n return try {\n LocalDate.parse(raw)\n .atStartOfDay(ZoneId.systemDefault())\n .toInstant()\n .toEpochMilli()\n } catch (_: Throwable) {\n LocalDate.now()\n .atStartOfDay(ZoneId.systemDefault())\n .toInstant()\n .toEpochMilli()\n }\n }\n\n private fun extractItems(payload: JSONObject): List<ParsedItem> {\n val arr = payload.optJSONArray(\"items\") ?: return emptyList()\n val out = mutableListOf<ParsedItem>()\n for (i in 0 until arr.length()) {\n val obj = arr.optJSONObject(i) ?: continue\n val name = obj.optString(\"name\", \"\").trim()\n if (name.isEmpty()) continue\n val amount = obj.opt(\"amount\")\n val amountDouble = when (amount) {\n is Number -> amount.toDouble()\n is String -> amount.toDoubleOrNull() ?: continue\n else -> continue\n }\n if (amountDouble <= 0.0 || amountDouble.isNaN()) continue\n val categoryId = extractCategoryId(obj)\n out += ParsedItem(name = name, amount = amountDouble, categoryId = categoryId)\n }\n return out\n }\n\n private fun extractCategoryId(itemObj: JSONObject): Int? {\n if (!itemObj.has(\"category_id\") || itemObj.isNull(\"category_id\")) return null\n return when (val v = itemObj.opt(\"category_id\")) {\n is Number -> v.toInt()\n is String -> v.toIntOrNull()\n else -> null\n }\n }\n\n private fun extractTotal(payload: JSONObject): Double? {\n if (!payload.has(\"total\") || payload.isNull(\"total\")) return null\n return when (val v = payload.opt(\"total\")) {\n is Number -> v.toDouble().takeUnless { it.isNaN() }\n is String -> v.toDoubleOrNull()\n else -> null\n }\n }\n }\n}\n"}}}}