From b028060dd5661339288a4f6d6d7a8d21ff33a3f6 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Wed, 12 Aug 2026 15:55:42 +0900 Subject: [PATCH] =?UTF-8?q?chore(cleanup):=20=EC=B0=B8=EC=A1=B0=200=20dead?= =?UTF-8?q?=20code=20=EC=A0=9C=EA=B1=B0=20(Category=20C=20=EC=B6=95?= =?UTF-8?q?=EC=86=8C=20=C2=B7=20demo-final)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 당초 조사 대상 5개 중 3개 (DfuBleScanner · DfuNus · DfuNusConnection) 는 DfuManager 실제 참조 확인 (컴파일 실패로 발견 · Explore 조사 오탐) → 유지. 완전 제거 (2 항목): · speech/HfVolumeExtractor.kt (298 L · 옛 HuggingFace API 실험 · 참조 0 검증) · res/raw/bladdy_loading3.riv (asset · 코드 참조 0 · bladdy_final12 만 사용) okhttp3 dependency 는 LabdbClient (labdb 세션 업로드) 에서 사용 중이라 유지. 합계: 약 298 LOC + asset 1개 감소. 빌드: BUILD SUCCESSFUL. --- .../vesiscan/speech/HfVolumeExtractor.kt | 298 ------------------ app/src/main/res/raw/bladdy_loading3.riv | Bin 18608 -> 0 bytes 2 files changed, 298 deletions(-) delete mode 100644 app/src/main/java/com/medithings/vesiscan/speech/HfVolumeExtractor.kt delete mode 100644 app/src/main/res/raw/bladdy_loading3.riv diff --git a/app/src/main/java/com/medithings/vesiscan/speech/HfVolumeExtractor.kt b/app/src/main/java/com/medithings/vesiscan/speech/HfVolumeExtractor.kt deleted file mode 100644 index 72c53c3..0000000 --- a/app/src/main/java/com/medithings/vesiscan/speech/HfVolumeExtractor.kt +++ /dev/null @@ -1,298 +0,0 @@ -/* - * Copyright 2026 Charles KWON (KWON Ohjun) - * charleskwon@medithings.co.kr / charleskwonohjun@gmail.com - * MEDiThings Inc. - * All rights reserved. - */ -package com.medithings.vesiscan.speech - -import android.util.Log -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import org.json.JSONArray -import org.json.JSONObject -import java.util.concurrent.TimeUnit - -/** - * Extracts a milliliter volume from speech-recognition text via the - * Hugging Face Inference API (Qwen 2.5-72B model). - * - * Selects a language-specific system prompt to maximize extraction accuracy. - * Falls back to [VolumeParser] when the API key is missing or the request fails. - * - * @param apiKey Hugging Face bearer token. - */ -class HfVolumeExtractor(private val apiKey: String) { - - /** Successful extraction result. */ - data class ExtractResult( - val volumeMl: Int, - val rawText: String, - val explanation: String, - ) - - private val client = OkHttpClient.Builder() - .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .build() - - /** - * Send [speechText] to the AI model using a prompt tailored to [language], - * and return the extracted volume, or `null` when extraction fails. - */ - suspend fun extract( - speechText: String, - language: SpeechLanguage = SpeechLanguage.KOREAN, - ): ExtractResult? = withContext(Dispatchers.IO) { - Log.d(TAG, "Starting extraction | lang=${language.code} | input: $speechText") - - if (apiKey.isBlank()) { - Log.w(TAG, "API key is blank; falling back to local parser") - return@withContext fallbackParse(speechText) - } - - try { - val prompt = getSystemPrompt(language) - val requestBody = buildRequestBody(speechText, prompt) - - val request = Request.Builder() - .url(API_URL) - .addHeader("Authorization", "Bearer $apiKey") - .addHeader("Content-Type", JSON_MEDIA_TYPE) - .post(requestBody.toRequestBody(JSON_MEDIA_TYPE.toMediaType())) - .build() - - val response = client.newCall(request).execute() - val body = response.body?.string() ?: return@withContext null - - Log.d(TAG, "Response code: ${response.code}") - - if (!response.isSuccessful) { - Log.e(TAG, "API failed (${response.code}); falling back to local parser") - return@withContext fallbackParse(speechText) - } - - parseApiResponse(body, speechText) - } catch (e: Exception) { - Log.e(TAG, "API error: ${e.message}", e) - fallbackParse(speechText) - } - } - - // ── Private helpers ───────────────────────────────────────────────── - - private fun buildRequestBody(speechText: String, systemPrompt: String): String = - JSONObject().apply { - put("model", MODEL_ID) - put("max_tokens", MAX_TOKENS) - put("stream", false) - put("messages", JSONArray().apply { - put(JSONObject().apply { - put("role", "system") - put("content", systemPrompt) - }) - put(JSONObject().apply { - put("role", "user") - put("content", speechText) - }) - }) - }.toString() - - private fun parseApiResponse(body: String, speechText: String): ExtractResult? { - val content = JSONObject(body) - .getJSONArray("choices") - .getJSONObject(0) - .getJSONObject("message") - .getString("content") - - Log.d(TAG, "Model response: $content") - - val jsonStr = extractJsonObject(content) - val resultJson = JSONObject(jsonStr) - val ml = resultJson.getInt("ml") - val explanation = resultJson.optString("explanation", "") - - Log.d(TAG, "Parsed result: ${ml}ml - $explanation") - - return if (ml in VALID_RANGE) { - ExtractResult(ml, speechText, explanation) - } else { - Log.w(TAG, "Volume out of range: ${ml}ml") - null - } - } - - private fun extractJsonObject(text: String): String { - val start = text.indexOf('{') - val end = text.lastIndexOf('}') - return if (start >= 0 && end > start) text.substring(start, end + 1) else text - } - - private fun fallbackParse(text: String): ExtractResult? { - val result = VolumeParser.parse(text) ?: return null - Log.d(TAG, "Local parse result: ${result.volumeMl}ml") - return ExtractResult(result.volumeMl, result.rawText, "Local parse") - } - - /** Return a system prompt optimized for the given [language]. */ - private fun getSystemPrompt(language: SpeechLanguage): String = when (language) { - SpeechLanguage.KOREAN -> PROMPT_KOREAN - SpeechLanguage.ENGLISH -> PROMPT_ENGLISH - SpeechLanguage.CHINESE -> PROMPT_CHINESE - SpeechLanguage.JAPANESE -> PROMPT_JAPANESE - } - - companion object { - private const val TAG = "HfVolumeExtractor" - private const val API_URL = "https://router.huggingface.co/v1/chat/completions" - private const val MODEL_ID = "Qwen/Qwen2.5-72B-Instruct" - private const val JSON_MEDIA_TYPE = "application/json" - private const val MAX_TOKENS = 100 - private const val CONNECT_TIMEOUT_SECONDS = 15L - private const val READ_TIMEOUT_SECONDS = 30L - private val VALID_RANGE = 1..1000 - - // ── Language-specific prompts ─────────────────────────────────── - - private val PROMPT_BASE = """ - ## Your Mission - You are specialized in extracting MILLILITER (ml) volumes from voice input. - The text you receive is raw speech-to-text output from a medical/lab device. - Your ONLY job: find the ml number the user intended to say. - - ## Rules - - Extract a single integer in ml, range 1~1000 - - mm and ml are the SAME in this context (user ALWAYS means ml, never millimeters) - - cc and ml are the SAME (1cc = 1ml) - - If ambiguous, prefer the most likely medical volume - - Look at ALL candidates (separated by " / ") to find consensus on the number - - If no valid volume found, return -1 - - Respond ONLY in this JSON format: - """.trimIndent() - - private val PROMPT_KOREAN = """ - You are a milliliter (ml) volume extraction AI. - - ## Context - This is a KOREAN speech-to-text result from a medical/lab voice input device. - The user spoke in KOREAN to input a liquid volume in milliliters (ml). - Your task: understand Korean speech and extract the ml value accurately. - - ## Input Format - Multiple STT candidates separated by " / " (e.g. "95mm / 95ml / 95 mm"). - STT often confuses mm/ml/미리/밀리 — the user ALWAYS means milliliters (ml). - - ## Korean-Specific Patterns - - Pure Korean numerals: "삼백 밀리", "백오십 미리" - - Numbers with units: "300ml", "150 미리" - - Korean-English mix: "삼백 ml", "원헌드레드 미리" - - Misheard Korean that is actually English: "쓰리헌드레드", "투헌드레드 피프티" - - Phonetic Korean of English: "파이브 헌드레드" = 500, "투 피프티" = 250 - - Casual/slang: "반 리터" = 500, "한 컵" = 250, "반" = 500, "쿼터" = 250 - - Unit variations: 밀리리터, 밀리미터, 미리, 밀리, ml, cc, 시시 - - ## Critical: Korean Phonetic Analysis - Korean STT may transcribe English words as Korean phonetics. - SOUND THEM OUT to decode: - - 쓰리 = three, 투 = two, 원 = one, 포 = four, 파이브 = five - - 식스 = six, 세븐 = seven, 에잇 = eight, 나인 = nine, 텐 = ten - - 헌드레드 = hundred, 피프티 = fifty, 트웬티 = twenty, 서티 = thirty - - 지로/제로 = zero, 오 = five OR "o" (context dependent) - - 더블 = double, 트리플 = triple - - $PROMPT_BASE - {"ml": , "explanation": "<한국어로 간단한 설명>"} - """.trimIndent() - - private val PROMPT_ENGLISH = """ - You are a milliliter (ml) volume extraction AI. - - ## Context - This is an ENGLISH speech-to-text result from a medical/lab voice input device. - The user spoke in ENGLISH to input a liquid volume in milliliters (ml). - Your task: understand English speech and extract the ml value accurately. - - ## Input Format - Multiple STT candidates separated by " / " (e.g. "95mm / 95ml / 95 mm"). - STT often confuses mm/ml — the user ALWAYS means milliliters (ml). - - ## English-Specific Patterns - - Standard: "three hundred milliliters", "one fifty ml" - - Digit-by-digit: "three zero zero" = 300, "one five o" = 150, "two o five" = 205 - - Casual: "half a liter" = 500, "a quarter liter" = 250, "half" = 500 - - Compound: "one hundred and fifty", "two fifty" - - With modifiers: "double zero" = 00, "triple zero" = 000 - - Unit variations: ml, milliliters, millimeters, cc - - $PROMPT_BASE - {"ml": , "explanation": ""} - """.trimIndent() - - private val PROMPT_CHINESE = """ - 你是一个毫升(ml)容量提取AI。 - - ## 背景 - 这是来自医疗/实验室语音输入设备的中文语音识别结果。 - 用户用中文说出了一个液体容量(毫升/ml)。 - 你的任务:准确理解中文语音并提取毫升值。 - - ## 输入格式 - 多个语音识别候选项用 " / " 分隔(例如 "95毫升 / 95ml / 九十五")。 - 语音识别经常混淆毫米/毫升——用户始终指的是毫升(ml)。 - - ## 中文特有模式 - - 中文数字: "三百毫升", "一百五十", "二百五" - - 阿拉伯数字+中文单位: "300毫升", "150ml" - - 口语: "半升" = 500, "一杯" = 250, "半" = 500 - - 口语简称: "二百五" = 250(此处不是骂人), "三百" = 300 - - 单位变体: 毫升, 毫米, ml, cc, 西西 - - ## 关键:中文数字规则 - - 一百五 = 150(一百五十的简称) - - 二百五 = 250(二百五十的简称) - - 两百 = 200 (两 = 2 in spoken Chinese) - - 半升 = 500ml, 四分之一升 = 250ml - - 一千 = 1000 - - $PROMPT_BASE - {"ml": , "explanation": "<用中文简要说明>"} - """.trimIndent() - - private val PROMPT_JAPANESE = """ - あなたはミリリットル(ml)容量抽出AIです。 - - ## 背景 - これは医療・実験室の音声入力デバイスからの日本語音声認識結果です。 - ユーザーは日本語で液体の容量(ミリリットル/ml)を発話しました。 - あなたのタスク:日本語の音声を正確に理解し、ml値を抽出すること。 - - ## 入力形式 - 複数の音声認識候補が " / " で区切られています(例:"95ミリ / 95ml / きゅうじゅうご")。 - 音声認識はミリメートル/ミリリットルを混同することが多い — ユーザーは常にミリリットル(ml)を意味します。 - - ## 日本語特有のパターン - - 日本語数字: "さんびゃくミリ", "ひゃくごじゅう" - - 漢数字: "三百ミリリットル", "百五十" - - 数字+単位: "300ml", "150ミリ" - - カジュアル: "半リットル" = 500, "コップ一杯" = 250 - - 省略形: "にひゃくごじゅう" = 250, "さんびゃく" = 300 - - 単位バリエーション: ミリリットル, ミリ, ml, cc, シーシー - - ## 重要:日本語数字ルール - - ひゃくごじゅう = 150 - - にひゃく = 200 - - にひゃくご = 250(にひゃくごじゅうの略) - - さんびゃく = 300 - - ごひゃく = 500 - - せん = 1000 - - 半分 = half (context: 半リットル = 500ml) - - $PROMPT_BASE - {"ml": , "explanation": "<日本語で簡単な説明>"} - """.trimIndent() - } -} diff --git a/app/src/main/res/raw/bladdy_loading3.riv b/app/src/main/res/raw/bladdy_loading3.riv deleted file mode 100644 index 6a8090153e720383f664566f58b89743cba0a6c8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18608 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 eVhKcKbvyyN2nq-jRuW((VYCwTVd)%_m;&CbpM