Route mic recording through MiMo ASR
#42
by ADJCJH - opened
- app.py +91 -1
- docs/local-space-mirror.md +2 -0
- dream_customs/app_logic.py +1 -1
- dream_customs/models.py +32 -8
- dream_customs/pipeline.py +141 -6
- dream_customs/prompts.py +17 -2
- dream_customs/ui/app.py +172 -73
- dream_customs/ui/copy.py +16 -8
- dream_customs/ui/styles.py +15 -5
- modal_backend/contracts.py +2 -2
- modal_backend/dream_customs_modal.py +89 -13
- scripts/local_space_mirror.py +89 -2
- tests/test_app_logic.py +20 -0
- tests/test_local_space_mirror.py +71 -2
- tests/test_modal_contract.py +1 -1
- tests/test_ollama_models.py +16 -0
- tests/test_pipeline.py +116 -0
- tests/test_ui_actions.py +26 -7
app.py
CHANGED
|
@@ -1,14 +1,52 @@
|
|
|
|
|
| 1 |
import os
|
|
|
|
| 2 |
|
| 3 |
from gradio.routes import App
|
| 4 |
from fastapi import Request
|
| 5 |
from fastapi.responses import JSONResponse, RedirectResponse
|
| 6 |
|
| 7 |
from dream_customs import zerogpu # noqa: F401
|
|
|
|
|
|
|
| 8 |
from dream_customs.runtime_env import auto_load_runtime_env_json
|
| 9 |
from dream_customs.ui.app import build_demo
|
| 10 |
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
auto_load_runtime_env_json()
|
| 13 |
demo = build_demo()
|
| 14 |
|
|
@@ -19,6 +57,58 @@ _ORIGINAL_CREATE_APP = App.create_app
|
|
| 19 |
def _install_gradio_api_aliases(app, blocks) -> None:
|
| 20 |
existing_paths = {getattr(route, "path", "") for route in app.routes}
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
if "/gradio_api/info" not in existing_paths:
|
| 23 |
@app.get("/gradio_api/info", include_in_schema=False)
|
| 24 |
async def gradio_api_info_alias() -> JSONResponse:
|
|
@@ -60,7 +150,7 @@ _install_gradio_api_aliases(demo.app, demo)
|
|
| 60 |
if __name__ == "__main__":
|
| 61 |
demo.launch(
|
| 62 |
server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
|
| 63 |
-
server_port=
|
| 64 |
show_api=False,
|
| 65 |
show_error=True,
|
| 66 |
)
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
import os
|
| 3 |
+
import tempfile
|
| 4 |
|
| 5 |
from gradio.routes import App
|
| 6 |
from fastapi import Request
|
| 7 |
from fastapi.responses import JSONResponse, RedirectResponse
|
| 8 |
|
| 9 |
from dream_customs import zerogpu # noqa: F401
|
| 10 |
+
from dream_customs.app_logic import _client_settings
|
| 11 |
+
from dream_customs.models import HostedASRClient
|
| 12 |
from dream_customs.runtime_env import auto_load_runtime_env_json
|
| 13 |
from dream_customs.ui.app import build_demo
|
| 14 |
|
| 15 |
|
| 16 |
+
LOCAL_GRADIO_PORT = 7862
|
| 17 |
+
HF_SPACE_GRADIO_PORT = 7860
|
| 18 |
+
BROWSER_ASR_TIMEOUT_SECONDS = 150.0
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _default_server_port() -> int:
|
| 22 |
+
configured_port = os.getenv("GRADIO_SERVER_PORT", "").strip()
|
| 23 |
+
if configured_port:
|
| 24 |
+
return int(configured_port)
|
| 25 |
+
if os.getenv("SPACE_ID", "").strip() or os.getenv("SPACE_HOST", "").strip():
|
| 26 |
+
return int(os.getenv("PORT", str(HF_SPACE_GRADIO_PORT)))
|
| 27 |
+
return LOCAL_GRADIO_PORT
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _browser_asr_timeout_seconds() -> float:
|
| 31 |
+
value = os.getenv("DREAM_CUSTOMS_BROWSER_ASR_TIMEOUT_SECONDS", "").strip()
|
| 32 |
+
if not value:
|
| 33 |
+
return BROWSER_ASR_TIMEOUT_SECONDS
|
| 34 |
+
try:
|
| 35 |
+
return max(10.0, float(value))
|
| 36 |
+
except ValueError:
|
| 37 |
+
return BROWSER_ASR_TIMEOUT_SECONDS
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _browser_asr_client() -> HostedASRClient:
|
| 41 |
+
resolved = _client_settings(asr_timeout_seconds=_browser_asr_timeout_seconds())
|
| 42 |
+
return HostedASRClient(
|
| 43 |
+
endpoint=resolved["asr_endpoint"],
|
| 44 |
+
token=resolved["hosted_token"],
|
| 45 |
+
timeout=resolved["asr_timeout_seconds"],
|
| 46 |
+
fallback_enabled=False,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
auto_load_runtime_env_json()
|
| 51 |
demo = build_demo()
|
| 52 |
|
|
|
|
| 57 |
def _install_gradio_api_aliases(app, blocks) -> None:
|
| 58 |
existing_paths = {getattr(route, "path", "") for route in app.routes}
|
| 59 |
|
| 60 |
+
if "/dream-asr" not in existing_paths:
|
| 61 |
+
@app.post("/dream-asr", include_in_schema=False)
|
| 62 |
+
async def dream_asr(request: Request) -> JSONResponse:
|
| 63 |
+
"""Transcribe a browser-recorded voice note through the server-side ASR client."""
|
| 64 |
+
|
| 65 |
+
form = await request.form()
|
| 66 |
+
upload = form.get("audio")
|
| 67 |
+
if upload is None or not hasattr(upload, "read"):
|
| 68 |
+
return JSONResponse(
|
| 69 |
+
{"status": "error", "transcript": "", "error": "Missing audio."},
|
| 70 |
+
status_code=400,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
filename = str(getattr(upload, "filename", "") or "dream-voice.webm")
|
| 74 |
+
suffix = os.path.splitext(filename)[1] or ".webm"
|
| 75 |
+
temp_path = ""
|
| 76 |
+
asr_client = None
|
| 77 |
+
try:
|
| 78 |
+
audio_bytes = await upload.read()
|
| 79 |
+
if not audio_bytes:
|
| 80 |
+
return JSONResponse(
|
| 81 |
+
{"status": "error", "transcript": "", "error": "Empty audio."},
|
| 82 |
+
status_code=400,
|
| 83 |
+
)
|
| 84 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
|
| 85 |
+
temp_file.write(audio_bytes)
|
| 86 |
+
temp_path = temp_file.name
|
| 87 |
+
asr_client = _browser_asr_client()
|
| 88 |
+
transcript = await asyncio.to_thread(asr_client.transcribe, temp_path)
|
| 89 |
+
finally:
|
| 90 |
+
if temp_path:
|
| 91 |
+
try:
|
| 92 |
+
os.unlink(temp_path)
|
| 93 |
+
except OSError:
|
| 94 |
+
pass
|
| 95 |
+
|
| 96 |
+
if not transcript:
|
| 97 |
+
return JSONResponse(
|
| 98 |
+
{
|
| 99 |
+
"status": "error",
|
| 100 |
+
"transcript": "",
|
| 101 |
+
"error": (
|
| 102 |
+
asr_client.last_error
|
| 103 |
+
if asr_client is not None
|
| 104 |
+
else "MiMo ASR client could not be initialized."
|
| 105 |
+
)
|
| 106 |
+
or "No transcript returned.",
|
| 107 |
+
},
|
| 108 |
+
status_code=502,
|
| 109 |
+
)
|
| 110 |
+
return JSONResponse({"status": "ok", "transcript": transcript})
|
| 111 |
+
|
| 112 |
if "/gradio_api/info" not in existing_paths:
|
| 113 |
@app.get("/gradio_api/info", include_in_schema=False)
|
| 114 |
async def gradio_api_info_alias() -> JSONResponse:
|
|
|
|
| 150 |
if __name__ == "__main__":
|
| 151 |
demo.launch(
|
| 152 |
server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
|
| 153 |
+
server_port=_default_server_port(),
|
| 154 |
show_api=False,
|
| 155 |
show_error=True,
|
| 156 |
)
|
docs/local-space-mirror.md
CHANGED
|
@@ -10,6 +10,8 @@ The mirror does not copy the app into a second implementation. It imports the sa
|
|
| 10 |
.venv/bin/python scripts/local_space_mirror.py
|
| 11 |
```
|
| 12 |
|
|
|
|
|
|
|
| 13 |
Open:
|
| 14 |
|
| 15 |
```text
|
|
|
|
| 10 |
.venv/bin/python scripts/local_space_mirror.py
|
| 11 |
```
|
| 12 |
|
| 13 |
+
By default, the wrapper keeps the local review app on port `7862`. If an older Dream QA app from this repository is already listening on that port, it stops that process before launching the current code. Use `--no-replace` to leave the old listener untouched.
|
| 14 |
+
|
| 15 |
Open:
|
| 16 |
|
| 17 |
```text
|
dream_customs/app_logic.py
CHANGED
|
@@ -37,7 +37,7 @@ DEFAULT_TEXT_MODEL = "hf.co/openbmb/MiniCPM5-1B-GGUF:Q8_0"
|
|
| 37 |
DEFAULT_VISION_MODEL = "openbmb/minicpm-v4.6"
|
| 38 |
DEFAULT_HOSTED_TIMEOUT_SECONDS = 60.0
|
| 39 |
DEFAULT_ASR_TIMEOUT_SECONDS = 45.0
|
| 40 |
-
DEFAULT_TEXT_TEMPERATURE = 0.
|
| 41 |
DEFAULT_VISION_TEMPERATURE = 0.1
|
| 42 |
DEFAULT_TEXT_MAX_TOKENS = 780
|
| 43 |
DEFAULT_VISION_MAX_TOKENS = 320
|
|
|
|
| 37 |
DEFAULT_VISION_MODEL = "openbmb/minicpm-v4.6"
|
| 38 |
DEFAULT_HOSTED_TIMEOUT_SECONDS = 60.0
|
| 39 |
DEFAULT_ASR_TIMEOUT_SECONDS = 45.0
|
| 40 |
+
DEFAULT_TEXT_TEMPERATURE = 0.0
|
| 41 |
DEFAULT_VISION_TEMPERATURE = 0.1
|
| 42 |
DEFAULT_TEXT_MAX_TOKENS = 780
|
| 43 |
DEFAULT_VISION_MAX_TOKENS = 320
|
dream_customs/models.py
CHANGED
|
@@ -194,7 +194,14 @@ def _extract_json_object(text: str) -> Optional[Dict[str, Any]]:
|
|
| 194 |
|
| 195 |
def _as_string_list(value: Any) -> List[str]:
|
| 196 |
if isinstance(value, list):
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
if isinstance(value, str) and value.strip():
|
| 199 |
return [part.strip() for part in re.split(r"[,,\n]", value) if part.strip()]
|
| 200 |
return []
|
|
@@ -310,7 +317,7 @@ class OllamaTextClient:
|
|
| 310 |
model_name: str = "hf.co/openbmb/MiniCPM5-1B-GGUF:Q8_0",
|
| 311 |
base_url: str = "http://localhost:11434",
|
| 312 |
timeout: float = 45.0,
|
| 313 |
-
temperature: float = 0.
|
| 314 |
max_tokens: int = 700,
|
| 315 |
fallback: Optional[FakeTextClient] = None,
|
| 316 |
):
|
|
@@ -618,7 +625,7 @@ class HostedMiniCPMTextClient:
|
|
| 618 |
endpoint: str = "",
|
| 619 |
token: str = "",
|
| 620 |
timeout: float = 60.0,
|
| 621 |
-
temperature: float = 0.
|
| 622 |
max_tokens: int = 780,
|
| 623 |
fallback: Optional[FakeTextClient] = None,
|
| 624 |
):
|
|
@@ -870,21 +877,32 @@ class HostedASRClient:
|
|
| 870 |
token: str = "",
|
| 871 |
timeout: float = 45.0,
|
| 872 |
fallback: Optional[FakeASRClient] = None,
|
|
|
|
| 873 |
):
|
| 874 |
self.endpoint = endpoint.strip()
|
| 875 |
self.token = token.strip()
|
| 876 |
self.timeout = timeout
|
| 877 |
self.fallback = fallback or FakeASRClient()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 878 |
|
| 879 |
def transcribe(self, audio_path: Optional[str]) -> str:
|
|
|
|
| 880 |
if not audio_path:
|
| 881 |
return ""
|
| 882 |
if not self.endpoint:
|
| 883 |
-
|
|
|
|
| 884 |
try:
|
| 885 |
with open(audio_path, "rb") as audio_file:
|
| 886 |
audio_b64 = base64.b64encode(audio_file.read()).decode("ascii")
|
| 887 |
-
except OSError:
|
|
|
|
| 888 |
return ""
|
| 889 |
payload = {
|
| 890 |
"audio": audio_b64,
|
|
@@ -902,6 +920,12 @@ class HostedASRClient:
|
|
| 902 |
try:
|
| 903 |
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
| 904 |
payload = json.loads(response.read().decode("utf-8"))
|
| 905 |
-
except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError):
|
| 906 |
-
|
| 907 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
def _as_string_list(value: Any) -> List[str]:
|
| 196 |
if isinstance(value, list):
|
| 197 |
+
items = []
|
| 198 |
+
for item in value:
|
| 199 |
+
if isinstance(item, dict):
|
| 200 |
+
item = item.get("question") or item.get("text") or item.get("content") or item.get("label") or ""
|
| 201 |
+
text = str(item).strip()
|
| 202 |
+
if text:
|
| 203 |
+
items.append(text)
|
| 204 |
+
return items
|
| 205 |
if isinstance(value, str) and value.strip():
|
| 206 |
return [part.strip() for part in re.split(r"[,,\n]", value) if part.strip()]
|
| 207 |
return []
|
|
|
|
| 317 |
model_name: str = "hf.co/openbmb/MiniCPM5-1B-GGUF:Q8_0",
|
| 318 |
base_url: str = "http://localhost:11434",
|
| 319 |
timeout: float = 45.0,
|
| 320 |
+
temperature: float = 0.0,
|
| 321 |
max_tokens: int = 700,
|
| 322 |
fallback: Optional[FakeTextClient] = None,
|
| 323 |
):
|
|
|
|
| 625 |
endpoint: str = "",
|
| 626 |
token: str = "",
|
| 627 |
timeout: float = 60.0,
|
| 628 |
+
temperature: float = 0.0,
|
| 629 |
max_tokens: int = 780,
|
| 630 |
fallback: Optional[FakeTextClient] = None,
|
| 631 |
):
|
|
|
|
| 877 |
token: str = "",
|
| 878 |
timeout: float = 45.0,
|
| 879 |
fallback: Optional[FakeASRClient] = None,
|
| 880 |
+
fallback_enabled: bool = True,
|
| 881 |
):
|
| 882 |
self.endpoint = endpoint.strip()
|
| 883 |
self.token = token.strip()
|
| 884 |
self.timeout = timeout
|
| 885 |
self.fallback = fallback or FakeASRClient()
|
| 886 |
+
self.fallback_enabled = fallback_enabled
|
| 887 |
+
self.last_error = ""
|
| 888 |
+
|
| 889 |
+
def _fallback_transcript(self, audio_path: Optional[str]) -> str:
|
| 890 |
+
if not self.fallback_enabled:
|
| 891 |
+
return ""
|
| 892 |
+
return self.fallback.transcribe(audio_path)
|
| 893 |
|
| 894 |
def transcribe(self, audio_path: Optional[str]) -> str:
|
| 895 |
+
self.last_error = ""
|
| 896 |
if not audio_path:
|
| 897 |
return ""
|
| 898 |
if not self.endpoint:
|
| 899 |
+
self.last_error = "Missing ASR endpoint."
|
| 900 |
+
return self._fallback_transcript(audio_path)
|
| 901 |
try:
|
| 902 |
with open(audio_path, "rb") as audio_file:
|
| 903 |
audio_b64 = base64.b64encode(audio_file.read()).decode("ascii")
|
| 904 |
+
except OSError as exc:
|
| 905 |
+
self.last_error = f"{exc.__class__.__name__}: {exc}"
|
| 906 |
return ""
|
| 907 |
payload = {
|
| 908 |
"audio": audio_b64,
|
|
|
|
| 920 |
try:
|
| 921 |
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
| 922 |
payload = json.loads(response.read().decode("utf-8"))
|
| 923 |
+
except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
| 924 |
+
self.last_error = f"{exc.__class__.__name__}: {exc}"
|
| 925 |
+
return self._fallback_transcript(audio_path)
|
| 926 |
+
transcript = _hosted_transcript_from_response(payload)
|
| 927 |
+
if transcript:
|
| 928 |
+
return transcript
|
| 929 |
+
error = payload.get("error") if isinstance(payload, dict) else ""
|
| 930 |
+
self.last_error = str(error or "No transcript returned.").strip()
|
| 931 |
+
return self._fallback_transcript(audio_path)
|
dream_customs/pipeline.py
CHANGED
|
@@ -178,6 +178,23 @@ _ZH_ANCHOR_MARKERS = [
|
|
| 178 |
"楼顶",
|
| 179 |
"办公楼",
|
| 180 |
"高的办公楼",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
"漆黑的走廊",
|
| 182 |
"漆黑走廊",
|
| 183 |
"黑暗走廊",
|
|
@@ -297,6 +314,23 @@ _ZH_TO_EN_PHRASES = {
|
|
| 297 |
"楼顶": "rooftop",
|
| 298 |
"办公楼": "office building",
|
| 299 |
"高的办公楼": "tall office building",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
"漆黑的走廊": "dark hallway",
|
| 301 |
"漆黑走廊": "dark hallway",
|
| 302 |
"黑暗走廊": "dark hallway",
|
|
@@ -486,6 +520,8 @@ def _extract_dream_anchors(intake: DreamIntake) -> List[str]:
|
|
| 486 |
candidates: List[str] = []
|
| 487 |
visual_candidates = _visual_anchor_candidates(intake)
|
| 488 |
candidates.extend(visual_candidates[:3])
|
|
|
|
|
|
|
| 489 |
if "elevator" in text:
|
| 490 |
candidates.append("elevator")
|
| 491 |
if re.search(r"\bfloor\s*14\b|\b14\b", text):
|
|
@@ -697,7 +733,7 @@ def _dream_theme(intake: DreamIntake, answers: str = "") -> str:
|
|
| 697 |
text = _story_text(intake, answers)
|
| 698 |
if _contains_any(text, ["小孩", "child", "找不到家", "lost", "home", "回家", "地铁", "subway"]):
|
| 699 |
return "lost_home"
|
| 700 |
-
if _contains_any(text, ["海", "海浪", "
|
| 701 |
return "dark_water"
|
| 702 |
if _contains_any(text, ["电梯", "按钮", "14", "elevator", "button", "floor"]):
|
| 703 |
return "stuck_elevator"
|
|
@@ -754,6 +790,8 @@ def _story_anchor_phrase(intake: DreamIntake, anchors: List[str], language: str
|
|
| 754 |
theme = _dream_theme(intake, answers)
|
| 755 |
if theme == "stuck_elevator":
|
| 756 |
return _stuck_elevator_anchor_phrase(intake, anchors, language, answers)
|
|
|
|
|
|
|
| 757 |
if _is_zh(language):
|
| 758 |
themed = {
|
| 759 |
"lost_home": "地铁站里迷路的小孩和回家的方向",
|
|
@@ -1169,13 +1207,16 @@ def _main_question_from_intake(intake: DreamIntake, language: str = "en") -> str
|
|
| 1169 |
def _fallback_interpretation(intake: DreamIntake, language: str = "en") -> str:
|
| 1170 |
primary = _primary_anchor(intake, language)
|
| 1171 |
secondary = _secondary_anchor(intake, language)
|
|
|
|
|
|
|
| 1172 |
if not _is_zh(language):
|
| 1173 |
return (
|
| 1174 |
-
|
| 1175 |
-
f"
|
|
|
|
| 1176 |
)
|
| 1177 |
return (
|
| 1178 |
-
f"也许这个梦不是在给你一个确定答案,而是把「{
|
| 1179 |
"提醒你先看见今天最卡住的一小处。"
|
| 1180 |
)
|
| 1181 |
|
|
@@ -1368,7 +1409,7 @@ def _grounded_today_tip(intake: DreamIntake, language: str = "en") -> str:
|
|
| 1368 |
)
|
| 1369 |
return _numbered_suggestions(
|
| 1370 |
[
|
| 1371 |
-
f"把「{
|
| 1372 |
"说出它像今天哪件普通小事,再选一个真的能做的小动作。",
|
| 1373 |
],
|
| 1374 |
language,
|
|
@@ -2038,6 +2079,82 @@ def build_qa_state(
|
|
| 2038 |
)
|
| 2039 |
|
| 2040 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2041 |
def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = "", language: str = "en") -> TodayTipCard:
|
| 2042 |
polished = card.model_copy(deep=True)
|
| 2043 |
answer_lines = [line.strip() for line in (answers or "").splitlines() if line.strip()]
|
|
@@ -2209,7 +2326,25 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
|
|
| 2209 |
elif _is_zh(language) and "所有楼层" in polished.caring_note:
|
| 2210 |
polished.caring_note = "你不需要一醒来就解释完整个梦;先照顾一个细节和一个很小的下一步就好。"
|
| 2211 |
merged = "\n".join([intake.merged_text(), answers or ""])
|
| 2212 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2213 |
if not _is_zh(language):
|
| 2214 |
polished = _clean_english_today_tip_language(polished)
|
| 2215 |
return polished
|
|
|
|
| 178 |
"楼顶",
|
| 179 |
"办公楼",
|
| 180 |
"高的办公楼",
|
| 181 |
+
"公司午觉",
|
| 182 |
+
"公司午休",
|
| 183 |
+
"公司",
|
| 184 |
+
"办公室",
|
| 185 |
+
"上班",
|
| 186 |
+
"午觉",
|
| 187 |
+
"午休",
|
| 188 |
+
"被人泼了一盆水",
|
| 189 |
+
"被人泼水",
|
| 190 |
+
"被泼了一盆水",
|
| 191 |
+
"被泼水",
|
| 192 |
+
"往脸上浇了一盆水",
|
| 193 |
+
"浇了一盆水",
|
| 194 |
+
"泼了一盆水",
|
| 195 |
+
"一盆水",
|
| 196 |
+
"盆水",
|
| 197 |
+
"泼水",
|
| 198 |
"漆黑的走廊",
|
| 199 |
"漆黑走廊",
|
| 200 |
"黑暗走廊",
|
|
|
|
| 314 |
"楼顶": "rooftop",
|
| 315 |
"办公楼": "office building",
|
| 316 |
"高的办公楼": "tall office building",
|
| 317 |
+
"公司午觉": "office nap",
|
| 318 |
+
"公司午休": "office lunch-break nap",
|
| 319 |
+
"公司": "company office",
|
| 320 |
+
"办公室": "office",
|
| 321 |
+
"上班": "being at work",
|
| 322 |
+
"午觉": "nap",
|
| 323 |
+
"午休": "lunch break",
|
| 324 |
+
"被人泼了一盆水": "someone pouring a basin of water",
|
| 325 |
+
"被人泼水": "someone splashing water",
|
| 326 |
+
"被泼了一盆水": "being splashed with a basin of water",
|
| 327 |
+
"被泼水": "being splashed with water",
|
| 328 |
+
"往脸上浇了一盆水": "water poured onto the face",
|
| 329 |
+
"浇了一盆水": "a basin of water being poured",
|
| 330 |
+
"泼了一盆水": "a basin of water being splashed",
|
| 331 |
+
"一盆水": "a basin of water",
|
| 332 |
+
"盆水": "basin of water",
|
| 333 |
+
"泼水": "splashing water",
|
| 334 |
"漆黑的走廊": "dark hallway",
|
| 335 |
"漆黑走廊": "dark hallway",
|
| 336 |
"黑暗走廊": "dark hallway",
|
|
|
|
| 520 |
candidates: List[str] = []
|
| 521 |
visual_candidates = _visual_anchor_candidates(intake)
|
| 522 |
candidates.extend(visual_candidates[:3])
|
| 523 |
+
if re.search(r"公司|办公室|上班", raw_text) and re.search(r"午觉|午休", raw_text) and re.search(r"泼|浇|水", raw_text):
|
| 524 |
+
candidates.extend(["公司", "午觉", "被人泼水"])
|
| 525 |
if "elevator" in text:
|
| 526 |
candidates.append("elevator")
|
| 527 |
if re.search(r"\bfloor\s*14\b|\b14\b", text):
|
|
|
|
| 733 |
text = _story_text(intake, answers)
|
| 734 |
if _contains_any(text, ["小孩", "child", "找不到家", "lost", "home", "回家", "地铁", "subway"]):
|
| 735 |
return "lost_home"
|
| 736 |
+
if _contains_any(text, ["海", "海浪", "月牙", "moon", "sea", "wave", "dark sea", "dark water", "漆黑的海"]):
|
| 737 |
return "dark_water"
|
| 738 |
if _contains_any(text, ["电梯", "按钮", "14", "elevator", "button", "floor"]):
|
| 739 |
return "stuck_elevator"
|
|
|
|
| 790 |
theme = _dream_theme(intake, answers)
|
| 791 |
if theme == "stuck_elevator":
|
| 792 |
return _stuck_elevator_anchor_phrase(intake, anchors, language, answers)
|
| 793 |
+
if anchors:
|
| 794 |
+
return _join_anchors(anchors, language)
|
| 795 |
if _is_zh(language):
|
| 796 |
themed = {
|
| 797 |
"lost_home": "地铁站里迷路的小孩和回家的方向",
|
|
|
|
| 1207 |
def _fallback_interpretation(intake: DreamIntake, language: str = "en") -> str:
|
| 1208 |
primary = _primary_anchor(intake, language)
|
| 1209 |
secondary = _secondary_anchor(intake, language)
|
| 1210 |
+
anchors = _anchors_for_language(intake, language)
|
| 1211 |
+
story_anchor = _story_anchor_phrase(intake, anchors, language) if anchors else primary
|
| 1212 |
if not _is_zh(language):
|
| 1213 |
return (
|
| 1214 |
+
"Maybe this dream is not giving you a fixed answer. "
|
| 1215 |
+
f"It is placing {_anchor_with_article(story_anchor)} near {_anchor_with_article(secondary)} "
|
| 1216 |
+
"so you can notice one small stuck point today."
|
| 1217 |
)
|
| 1218 |
return (
|
| 1219 |
+
f"也许这个梦不是在给你一个确定答案,而是把「{story_anchor}」这组线索放在一起,"
|
| 1220 |
"提醒你先看见今天最卡住的一小处。"
|
| 1221 |
)
|
| 1222 |
|
|
|
|
| 1409 |
)
|
| 1410 |
return _numbered_suggestions(
|
| 1411 |
[
|
| 1412 |
+
f"把「{anchor}」当成现实生活的线索,不当成梦里给你的命令。",
|
| 1413 |
"说出它像今天哪件普通小事,再选一个真的能做的小动作。",
|
| 1414 |
],
|
| 1415 |
language,
|
|
|
|
| 2079 |
)
|
| 2080 |
|
| 2081 |
|
| 2082 |
+
def _has_unsupported_clinical_frame(text: str) -> bool:
|
| 2083 |
+
clean = (text or "").lower()
|
| 2084 |
+
markers = [
|
| 2085 |
+
"睡眠剥夺",
|
| 2086 |
+
"睡眠障碍",
|
| 2087 |
+
"睡眠问题",
|
| 2088 |
+
"压力过大",
|
| 2089 |
+
"压力过载",
|
| 2090 |
+
"压力信号",
|
| 2091 |
+
"持续焦虑",
|
| 2092 |
+
"焦虑症",
|
| 2093 |
+
"寻求专业帮助",
|
| 2094 |
+
"专业帮助",
|
| 2095 |
+
"专业支持",
|
| 2096 |
+
"心理咨询",
|
| 2097 |
+
"心理治疗",
|
| 2098 |
+
"医疗建议",
|
| 2099 |
+
"临床",
|
| 2100 |
+
"诊断",
|
| 2101 |
+
"病理",
|
| 2102 |
+
"创伤证据",
|
| 2103 |
+
"sleep deprivation",
|
| 2104 |
+
"sleep disorder",
|
| 2105 |
+
"sleep problem",
|
| 2106 |
+
"pressure overload",
|
| 2107 |
+
"professional help",
|
| 2108 |
+
"professional support",
|
| 2109 |
+
"medical advice",
|
| 2110 |
+
"clinical",
|
| 2111 |
+
"diagnosis",
|
| 2112 |
+
"therapy",
|
| 2113 |
+
"pathology",
|
| 2114 |
+
"trauma evidence",
|
| 2115 |
+
]
|
| 2116 |
+
return any(marker in clean for marker in markers)
|
| 2117 |
+
|
| 2118 |
+
|
| 2119 |
+
def _has_unsupported_emotion_or_generic_wellness(text: str, intake: DreamIntake, answers: str = "") -> bool:
|
| 2120 |
+
clean = (text or "").lower()
|
| 2121 |
+
if not clean:
|
| 2122 |
+
return False
|
| 2123 |
+
source = _story_text(intake, answers)
|
| 2124 |
+
unsupported_without_source = [
|
| 2125 |
+
(["工作压力", "压力"], ["压力", "焦虑", "stress", "stressed", "pressure", "overwhelmed"]),
|
| 2126 |
+
(["害怕", "恐惧", "scared", "afraid"], ["害怕", "怕", "恐惧", "scared", "afraid"]),
|
| 2127 |
+
(["孤独", "lonely"], ["孤独", "lonely"]),
|
| 2128 |
+
(["焦虑", "anxious"], ["焦虑", "anxious"]),
|
| 2129 |
+
(["自责", "guilt", "guilty"], ["自责", "内疚", "guilt", "guilty"]),
|
| 2130 |
+
]
|
| 2131 |
+
for output_markers, source_markers in unsupported_without_source:
|
| 2132 |
+
if any(marker in clean for marker in output_markers) and not any(marker in source for marker in source_markers):
|
| 2133 |
+
return True
|
| 2134 |
+
generic_markers = [
|
| 2135 |
+
"保持积极",
|
| 2136 |
+
"积极心态",
|
| 2137 |
+
"明天会更好",
|
| 2138 |
+
"喝一杯温水",
|
| 2139 |
+
"一杯温水",
|
| 2140 |
+
"温水",
|
| 2141 |
+
"轻松的音乐",
|
| 2142 |
+
"relaxing music",
|
| 2143 |
+
"stay positive",
|
| 2144 |
+
"positive mindset",
|
| 2145 |
+
"tomorrow will be better",
|
| 2146 |
+
"warm water",
|
| 2147 |
+
]
|
| 2148 |
+
return any(marker in clean for marker in generic_markers)
|
| 2149 |
+
|
| 2150 |
+
|
| 2151 |
+
def _nonclinical_caring_note(anchors: List[str], language: str = "en") -> str:
|
| 2152 |
+
anchor = anchors[0] if anchors else ("梦里的这个细节" if _is_zh(language) else "this dream detail")
|
| 2153 |
+
if _is_zh(language):
|
| 2154 |
+
return f"这个梦被你记下来已经够了;今天先把「{anchor}」当作一个需要被温柔看见的细节。"
|
| 2155 |
+
return f"Writing this dream down is already enough; today, let {anchor} be one detail you meet gently."
|
| 2156 |
+
|
| 2157 |
+
|
| 2158 |
def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = "", language: str = "en") -> TodayTipCard:
|
| 2159 |
polished = card.model_copy(deep=True)
|
| 2160 |
answer_lines = [line.strip() for line in (answers or "").splitlines() if line.strip()]
|
|
|
|
| 2326 |
elif _is_zh(language) and "所有楼层" in polished.caring_note:
|
| 2327 |
polished.caring_note = "你不需要一醒来就解释完整个梦;先照顾一个细节和一个很小的下一步就好。"
|
| 2328 |
merged = "\n".join([intake.merged_text(), answers or ""])
|
| 2329 |
+
has_escalation = needs_escalation(merged)
|
| 2330 |
+
if not has_escalation:
|
| 2331 |
+
if _has_unsupported_clinical_frame(polished.interpretation) or _has_unsupported_emotion_or_generic_wellness(
|
| 2332 |
+
polished.interpretation, intake, answers
|
| 2333 |
+
):
|
| 2334 |
+
polished.interpretation = _fallback_interpretation(intake, language)
|
| 2335 |
+
if _has_unsupported_clinical_frame(polished.today_tip) or _has_unsupported_emotion_or_generic_wellness(
|
| 2336 |
+
polished.today_tip, intake, answers
|
| 2337 |
+
):
|
| 2338 |
+
polished.today_tip = _grounded_today_tip(intake, language)
|
| 2339 |
+
if _has_unsupported_clinical_frame(polished.tiny_action) or _has_unsupported_emotion_or_generic_wellness(
|
| 2340 |
+
polished.tiny_action, intake, answers
|
| 2341 |
+
):
|
| 2342 |
+
polished.tiny_action = _weird_little_action(intake, answers, anchors, language)
|
| 2343 |
+
if _has_unsupported_clinical_frame(polished.caring_note) or _has_unsupported_emotion_or_generic_wellness(
|
| 2344 |
+
polished.caring_note, intake, answers
|
| 2345 |
+
):
|
| 2346 |
+
polished.caring_note = _nonclinical_caring_note(anchors, language)
|
| 2347 |
+
polished.safety_note = safety_note(language) if has_escalation else ""
|
| 2348 |
if not _is_zh(language):
|
| 2349 |
polished = _clean_english_today_tip_language(polished)
|
| 2350 |
return polished
|
dream_customs/prompts.py
CHANGED
|
@@ -33,6 +33,10 @@ You are MiniCPM5-1B acting as Dream QA / 梦境问答台, a gentle question guid
|
|
| 33 |
Summarize the dream, infer the user's main question if they did not write one, and ask 1 to 3 warm follow-up questions.
|
| 34 |
Do not diagnose, predict fate, frighten the user, or claim one fixed dream meaning.
|
| 35 |
Ground every question in a concrete detail from the text, voice transcript, mood, or visual clues.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
{_language_instruction(language)}
|
| 37 |
|
| 38 |
Dream intake:
|
|
@@ -67,7 +71,14 @@ use real-world physics or an ordinary physical object, and create one strange, p
|
|
| 67 |
the user can actually do in 1 to 5 minutes while awake. It should feel random and fresh, not like a stock
|
| 68 |
self-check, journaling prompt, breathing exercise, or generic productivity hack.
|
| 69 |
The today_tip and tiny_action must change with the user's story, visual evidence, and follow-up answers.
|
|
|
|
|
|
|
| 70 |
Avoid prophecy, frightening certainty, medical advice, therapy framing, and generic wellness filler.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
Keep the whole result short, warm, emotionally responsive, and specific to the user's answer.
|
| 72 |
The weird little thing must be harmless, legal, low-cost, non-embarrassing, and not a command to solve the whole problem.
|
| 73 |
Avoid demanding phrases such as "immediately", "must", "fix it", or "solve it".
|
|
@@ -191,6 +202,8 @@ Ask questions that an ordinary person can understand without knowing any app lor
|
|
| 191 |
Prefer questions about the strongest feeling, one confusing scene, or one safe next-day reference.
|
| 192 |
Ground every question in a concrete detail from the intake when possible, such as an object,
|
| 193 |
place, action, color, or phrase the user actually provided.
|
|
|
|
|
|
|
| 194 |
{_language_instruction(language)}
|
| 195 |
|
| 196 |
Dream intake:
|
|
@@ -198,7 +211,7 @@ Dream intake:
|
|
| 198 |
|
| 199 |
Return JSON with:
|
| 200 |
- visitor_name: short vivid anchor label
|
| 201 |
-
- questions: 1 to 3 gentle, specific, easy-to-understand questions
|
| 202 |
- tone_note: one sentence explaining why the questions may help without certainty
|
| 203 |
""".strip()
|
| 204 |
|
|
@@ -219,6 +232,8 @@ or whether one concrete dream detail connects to today.
|
|
| 219 |
Do not use unclear metaphors about fate, symbols, hidden meanings, stamps, release, or permits.
|
| 220 |
Reuse one concrete dream detail from the intake so the user can feel the question belongs
|
| 221 |
to this dream rather than to a generic reflection form.
|
|
|
|
|
|
|
| 222 |
{_language_instruction(language)}
|
| 223 |
|
| 224 |
Dream intake:
|
|
@@ -232,7 +247,7 @@ User answers:
|
|
| 232 |
|
| 233 |
Return JSON with:
|
| 234 |
- visitor_name: short vivid name
|
| 235 |
-
- questions: one gentle, specific question in a single-item list
|
| 236 |
- tone_note: one sentence explaining why this question matters today
|
| 237 |
""".strip()
|
| 238 |
|
|
|
|
| 33 |
Summarize the dream, infer the user's main question if they did not write one, and ask 1 to 3 warm follow-up questions.
|
| 34 |
Do not diagnose, predict fate, frighten the user, or claim one fixed dream meaning.
|
| 35 |
Ground every question in a concrete detail from the text, voice transcript, mood, or visual clues.
|
| 36 |
+
Use the Dream intake as the source of truth. Do not add scenes, characters, places, objects, times of day, or emotions
|
| 37 |
+
that are not explicitly present. If the intake is short or has only one detail, ask a clarifying question instead of
|
| 38 |
+
expanding it into a richer scene. For example, do not turn "water" into sea, waves, moonlight, or a person unless those
|
| 39 |
+
details appear in the intake.
|
| 40 |
{_language_instruction(language)}
|
| 41 |
|
| 42 |
Dream intake:
|
|
|
|
| 71 |
the user can actually do in 1 to 5 minutes while awake. It should feel random and fresh, not like a stock
|
| 72 |
self-check, journaling prompt, breathing exercise, or generic productivity hack.
|
| 73 |
The today_tip and tiny_action must change with the user's story, visual evidence, and follow-up answers.
|
| 74 |
+
Use only dream facts present in the Dream QA state. Do not add scenes, people, places, objects, or time cues that are
|
| 75 |
+
not in dream_summary, dream_anchors, followup_questions, or user_answers. If an anchor is minimal, keep it minimal.
|
| 76 |
Avoid prophecy, frightening certainty, medical advice, therapy framing, and generic wellness filler.
|
| 77 |
+
Do not call an ordinary dream a sign of sleep deprivation, a sleep problem, pressure overload, trauma evidence,
|
| 78 |
+
or a reason to seek professional help unless the user explicitly reports severe insomnia, severe distress, panic,
|
| 79 |
+
self-harm, harm to others, or inability to function.
|
| 80 |
+
Do not infer work stress, fear, loneliness, anxiety, guilt, or self-blame unless the user actually says that feeling.
|
| 81 |
+
Avoid generic wellness filler such as warm water, relaxing music, positive mindset, or "tomorrow will be better".
|
| 82 |
Keep the whole result short, warm, emotionally responsive, and specific to the user's answer.
|
| 83 |
The weird little thing must be harmless, legal, low-cost, non-embarrassing, and not a command to solve the whole problem.
|
| 84 |
Avoid demanding phrases such as "immediately", "must", "fix it", or "solve it".
|
|
|
|
| 202 |
Prefer questions about the strongest feeling, one confusing scene, or one safe next-day reference.
|
| 203 |
Ground every question in a concrete detail from the intake when possible, such as an object,
|
| 204 |
place, action, color, or phrase the user actually provided.
|
| 205 |
+
Never invent supporting scenery around a short anchor. If the user says water, ask about water; do not make it sea,
|
| 206 |
+
waves, moonlight, or a small figure unless those words came from text, voice, or visual clues.
|
| 207 |
{_language_instruction(language)}
|
| 208 |
|
| 209 |
Dream intake:
|
|
|
|
| 211 |
|
| 212 |
Return JSON with:
|
| 213 |
- visitor_name: short vivid anchor label
|
| 214 |
+
- questions: 1 to 3 gentle, specific, easy-to-understand questions as plain strings, not objects
|
| 215 |
- tone_note: one sentence explaining why the questions may help without certainty
|
| 216 |
""".strip()
|
| 217 |
|
|
|
|
| 232 |
Do not use unclear metaphors about fate, symbols, hidden meanings, stamps, release, or permits.
|
| 233 |
Reuse one concrete dream detail from the intake so the user can feel the question belongs
|
| 234 |
to this dream rather than to a generic reflection form.
|
| 235 |
+
Use only details explicitly present in the intake, previous questions, or user answers. Do not expand one word into a
|
| 236 |
+
larger imagined scene; if context is missing, ask for the missing context.
|
| 237 |
{_language_instruction(language)}
|
| 238 |
|
| 239 |
Dream intake:
|
|
|
|
| 247 |
|
| 248 |
Return JSON with:
|
| 249 |
- visitor_name: short vivid name
|
| 250 |
+
- questions: one gentle, specific question in a single-item list of plain strings, not objects
|
| 251 |
- tone_note: one sentence explaining why this question matters today
|
| 252 |
""".strip()
|
| 253 |
|
dream_customs/ui/app.py
CHANGED
|
@@ -89,13 +89,18 @@ VOICE_JS = r"""
|
|
| 89 |
button.dataset.bound = "true";
|
| 90 |
|
| 91 |
const messageFor = (key, fallback) => button.dataset[key] || fallback;
|
| 92 |
-
let
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
const setStatus = (message, mode) => {
|
| 95 |
-
if (mode !== "listening" && checkingTimer) {
|
| 96 |
-
window.clearTimeout(checkingTimer);
|
| 97 |
-
checkingTimer = null;
|
| 98 |
-
}
|
| 99 |
if (status) {
|
| 100 |
status.textContent = message;
|
| 101 |
status.dataset.mode = mode || "";
|
|
@@ -109,84 +114,172 @@ VOICE_JS = r"""
|
|
| 109 |
if (!transcript) {
|
| 110 |
return;
|
| 111 |
}
|
| 112 |
-
const
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
};
|
| 118 |
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
textarea.focus();
|
| 124 |
-
return;
|
| 125 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
setStatus(messageFor("timeout", "Voice is taking too long here. You can keep typing the dream instead."), "error");
|
| 138 |
-
try {
|
| 139 |
-
recognition.stop();
|
| 140 |
-
} catch (_error) {
|
| 141 |
-
// Best-effort stop for browsers that leave speech recognition pending.
|
| 142 |
-
}
|
| 143 |
-
textarea.focus();
|
| 144 |
-
}
|
| 145 |
-
}, 3000);
|
| 146 |
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
}
|
| 152 |
-
setStatus(messageFor("listening", "Listening. Say the dream fragment when you are ready."), "listening");
|
| 153 |
-
};
|
| 154 |
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
}
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
if (
|
| 167 |
-
|
| 168 |
-
|
|
|
|
| 169 |
}
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
}
|
| 181 |
-
if (
|
| 182 |
-
|
| 183 |
-
setStatus(messageFor("done", "Added to the dream note."), "done");
|
| 184 |
-
} else if (button.dataset.mode === "listening") {
|
| 185 |
-
setStatus(messageFor("empty", "No speech detected. Tap again if you want to retry."), "idle");
|
| 186 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
});
|
| 191 |
};
|
| 192 |
|
|
@@ -740,14 +833,20 @@ def _mic_html(language: str = DEFAULT_LANGUAGE) -> str:
|
|
| 740 |
type="button"
|
| 741 |
class="dc-mic-button"
|
| 742 |
aria-label="{escape(copy['mic_idle'])}"
|
|
|
|
| 743 |
data-language="{escape(language)}"
|
| 744 |
-
data-
|
| 745 |
-
data-
|
|
|
|
|
|
|
|
|
|
| 746 |
data-unsupported="{escape(copy['mic_unsupported'])}"
|
| 747 |
data-permission="{escape(copy['mic_permission'])}"
|
| 748 |
-
data-listening="{escape(copy['mic_listening'])}"
|
| 749 |
data-done="{escape(copy['mic_done'])}"
|
| 750 |
data-empty="{escape(copy['mic_empty'])}"
|
|
|
|
|
|
|
|
|
|
| 751 |
>
|
| 752 |
<span class="dc-mic-glyph" aria-hidden="true"></span>
|
| 753 |
</button>
|
|
|
|
| 89 |
button.dataset.bound = "true";
|
| 90 |
|
| 91 |
const messageFor = (key, fallback) => button.dataset[key] || fallback;
|
| 92 |
+
let mediaRecorder = null;
|
| 93 |
+
let mediaStream = null;
|
| 94 |
+
let chunks = [];
|
| 95 |
+
let audioContext = null;
|
| 96 |
+
let silenceTimer = null;
|
| 97 |
+
let maxTimer = null;
|
| 98 |
+
let wakingTimer = null;
|
| 99 |
+
let requestTimer = null;
|
| 100 |
+
let heardVoice = false;
|
| 101 |
+
let lastVoiceAt = 0;
|
| 102 |
|
| 103 |
const setStatus = (message, mode) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
if (status) {
|
| 105 |
status.textContent = message;
|
| 106 |
status.dataset.mode = mode || "";
|
|
|
|
| 114 |
if (!transcript) {
|
| 115 |
return;
|
| 116 |
}
|
| 117 |
+
const prefix = textarea.value.trim() ? "\n" : "";
|
| 118 |
+
let index = 0;
|
| 119 |
+
const writeNext = () => {
|
| 120 |
+
const next = transcript.slice(index, index + 2);
|
| 121 |
+
textarea.value = `${textarea.value}${next}`;
|
| 122 |
+
textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: next }));
|
| 123 |
+
textarea.dispatchEvent(new Event("change", { bubbles: true }));
|
| 124 |
+
index += next.length;
|
| 125 |
+
if (index < transcript.length) {
|
| 126 |
+
window.setTimeout(writeNext, 24);
|
| 127 |
+
} else {
|
| 128 |
+
textarea.focus();
|
| 129 |
+
}
|
| 130 |
+
};
|
| 131 |
+
textarea.value = `${textarea.value}${prefix}`;
|
| 132 |
+
writeNext();
|
| 133 |
};
|
| 134 |
|
| 135 |
+
const cleanupMedia = () => {
|
| 136 |
+
if (silenceTimer) {
|
| 137 |
+
window.clearInterval(silenceTimer);
|
| 138 |
+
silenceTimer = null;
|
|
|
|
|
|
|
| 139 |
}
|
| 140 |
+
if (maxTimer) {
|
| 141 |
+
window.clearTimeout(maxTimer);
|
| 142 |
+
maxTimer = null;
|
| 143 |
+
}
|
| 144 |
+
mediaStream?.getTracks().forEach((track) => track.stop());
|
| 145 |
+
mediaStream = null;
|
| 146 |
+
if (audioContext && audioContext.state !== "closed") {
|
| 147 |
+
audioContext.close().catch(() => {});
|
| 148 |
+
}
|
| 149 |
+
audioContext = null;
|
| 150 |
+
};
|
| 151 |
|
| 152 |
+
const clearRequestTimers = () => {
|
| 153 |
+
if (wakingTimer) {
|
| 154 |
+
window.clearTimeout(wakingTimer);
|
| 155 |
+
wakingTimer = null;
|
| 156 |
+
}
|
| 157 |
+
if (requestTimer) {
|
| 158 |
+
window.clearTimeout(requestTimer);
|
| 159 |
+
requestTimer = null;
|
| 160 |
+
}
|
| 161 |
+
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
+
const chooseMimeType = () => {
|
| 164 |
+
const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/wav"];
|
| 165 |
+
return candidates.find((type) => window.MediaRecorder?.isTypeSupported?.(type)) || "";
|
| 166 |
+
};
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
+
const uploadAudio = async (blob) => {
|
| 169 |
+
if (!blob || blob.size === 0) {
|
| 170 |
+
setStatus(messageFor("empty", "No speech detected. Tap again if you want to retry."), "error");
|
| 171 |
+
return;
|
| 172 |
+
}
|
| 173 |
+
setStatus(messageFor("transcribing", "Transcribing with MiMo ASR..."), "transcribing");
|
| 174 |
+
const form = new FormData();
|
| 175 |
+
const extension = blob.type.includes("mp4") ? "m4a" : blob.type.includes("wav") ? "wav" : "webm";
|
| 176 |
+
form.append("audio", blob, `dream-voice.${extension}`);
|
| 177 |
+
clearRequestTimers();
|
| 178 |
+
const controller = new AbortController();
|
| 179 |
+
const asrRequestTimeout = Number(button.dataset.timeoutMs || "140000");
|
| 180 |
+
wakingTimer = window.setTimeout(() => {
|
| 181 |
+
setStatus(messageFor("waking", "MiMo ASR is waking on Modal. This first pass can take a moment."), "waking");
|
| 182 |
+
}, 6000);
|
| 183 |
+
requestTimer = window.setTimeout(() => {
|
| 184 |
+
controller.abort();
|
| 185 |
+
}, asrRequestTimeout);
|
| 186 |
+
try {
|
| 187 |
+
const response = await fetch("/dream-asr", { method: "POST", body: form, signal: controller.signal });
|
| 188 |
+
const payload = await response.json();
|
| 189 |
+
if (!response.ok || payload.status !== "ok" || !payload.transcript) {
|
| 190 |
+
throw new Error(payload.error || "No transcript returned.");
|
| 191 |
}
|
| 192 |
+
appendTranscript(payload.transcript);
|
| 193 |
+
setStatus(messageFor("done", "Added the ASR transcript to the dream note."), "done");
|
| 194 |
+
} catch (error) {
|
| 195 |
+
if (error?.name === "AbortError") {
|
| 196 |
+
setStatus(messageFor("timeout", "MiMo ASR timed out. Tap the mic to try once more."), "error");
|
| 197 |
+
} else {
|
| 198 |
+
setStatus(messageFor("error", "Voice transcription failed. You can type this fragment instead."), "error");
|
| 199 |
}
|
| 200 |
+
} finally {
|
| 201 |
+
clearRequestTimers();
|
| 202 |
+
}
|
| 203 |
+
};
|
| 204 |
+
|
| 205 |
+
const stopRecording = () => {
|
| 206 |
+
if (mediaRecorder && mediaRecorder.state === "recording") {
|
| 207 |
+
mediaRecorder.stop();
|
| 208 |
+
}
|
| 209 |
+
};
|
| 210 |
|
| 211 |
+
const watchSilence = (stream) => {
|
| 212 |
+
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
| 213 |
+
if (!AudioContext) {
|
| 214 |
+
return;
|
| 215 |
+
}
|
| 216 |
+
audioContext = new AudioContext();
|
| 217 |
+
const source = audioContext.createMediaStreamSource(stream);
|
| 218 |
+
const analyser = audioContext.createAnalyser();
|
| 219 |
+
analyser.fftSize = 1024;
|
| 220 |
+
source.connect(analyser);
|
| 221 |
+
const samples = new Uint8Array(analyser.fftSize);
|
| 222 |
+
const startedAt = Date.now();
|
| 223 |
+
silenceTimer = window.setInterval(() => {
|
| 224 |
+
analyser.getByteTimeDomainData(samples);
|
| 225 |
+
let total = 0;
|
| 226 |
+
for (const sample of samples) {
|
| 227 |
+
const value = (sample - 128) / 128;
|
| 228 |
+
total += value * value;
|
| 229 |
+
}
|
| 230 |
+
const rms = Math.sqrt(total / samples.length);
|
| 231 |
+
const now = Date.now();
|
| 232 |
+
if (rms > 0.035) {
|
| 233 |
+
heardVoice = true;
|
| 234 |
+
lastVoiceAt = now;
|
| 235 |
}
|
| 236 |
+
if (heardVoice && now - lastVoiceAt > 1300 && now - startedAt > 1200) {
|
| 237 |
+
stopRecording();
|
|
|
|
|
|
|
|
|
|
| 238 |
}
|
| 239 |
+
}, 180);
|
| 240 |
+
};
|
| 241 |
+
|
| 242 |
+
const startRecording = async () => {
|
| 243 |
+
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
|
| 244 |
+
setStatus(messageFor("unsupported", "This browser cannot record audio here. You can still type the dream."), "error");
|
| 245 |
+
return;
|
| 246 |
+
}
|
| 247 |
+
try {
|
| 248 |
+
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 249 |
+
} catch (_error) {
|
| 250 |
+
setStatus(messageFor("permission", "Microphone permission was not granted. Allow recording and try again."), "error");
|
| 251 |
+
return;
|
| 252 |
+
}
|
| 253 |
+
chunks = [];
|
| 254 |
+
heardVoice = false;
|
| 255 |
+
lastVoiceAt = Date.now();
|
| 256 |
+
const mimeType = chooseMimeType();
|
| 257 |
+
mediaRecorder = new MediaRecorder(mediaStream, mimeType ? { mimeType } : undefined);
|
| 258 |
+
mediaRecorder.ondataavailable = (event) => {
|
| 259 |
+
if (event.data?.size) {
|
| 260 |
+
chunks.push(event.data);
|
| 261 |
+
}
|
| 262 |
+
};
|
| 263 |
+
mediaRecorder.onstop = () => {
|
| 264 |
+
cleanupMedia();
|
| 265 |
+
const type = mediaRecorder?.mimeType || mimeType || "audio/webm";
|
| 266 |
+
const blob = new Blob(chunks, { type });
|
| 267 |
+
mediaRecorder = null;
|
| 268 |
+
uploadAudio(blob);
|
| 269 |
};
|
| 270 |
+
mediaRecorder.start();
|
| 271 |
+
watchSilence(mediaStream);
|
| 272 |
+
maxTimer = window.setTimeout(stopRecording, 30000);
|
| 273 |
+
setStatus(messageFor("recording", "Listening. Tap again to stop, or pause and I will transcribe."), "recording");
|
| 274 |
+
};
|
| 275 |
|
| 276 |
+
button.addEventListener("click", () => {
|
| 277 |
+
if (mediaRecorder?.state === "recording") {
|
| 278 |
+
stopRecording();
|
| 279 |
+
} else {
|
| 280 |
+
clearRequestTimers();
|
| 281 |
+
startRecording();
|
| 282 |
+
}
|
| 283 |
});
|
| 284 |
};
|
| 285 |
|
|
|
|
| 833 |
type="button"
|
| 834 |
class="dc-mic-button"
|
| 835 |
aria-label="{escape(copy['mic_idle'])}"
|
| 836 |
+
aria-expanded="false"
|
| 837 |
data-language="{escape(language)}"
|
| 838 |
+
data-idle="{escape(copy['mic_idle'])}"
|
| 839 |
+
data-open="{escape(copy['voice_help'])}"
|
| 840 |
+
data-recording="{escape(copy['mic_listening'])}"
|
| 841 |
+
data-transcribing="{escape(copy['mic_transcribing'])}"
|
| 842 |
+
data-waking="{escape(copy['mic_waking'])}"
|
| 843 |
data-unsupported="{escape(copy['mic_unsupported'])}"
|
| 844 |
data-permission="{escape(copy['mic_permission'])}"
|
|
|
|
| 845 |
data-done="{escape(copy['mic_done'])}"
|
| 846 |
data-empty="{escape(copy['mic_empty'])}"
|
| 847 |
+
data-error="{escape(copy['mic_error'])}"
|
| 848 |
+
data-timeout="{escape(copy['mic_timeout'])}"
|
| 849 |
+
data-timeout-ms="140000"
|
| 850 |
>
|
| 851 |
<span class="dc-mic-glyph" aria-hidden="true"></span>
|
| 852 |
</button>
|
dream_customs/ui/copy.py
CHANGED
|
@@ -22,14 +22,18 @@ APP_COPY = {
|
|
| 22 |
"notice_error": "Dream QA needs a dream fragment before it can continue.",
|
| 23 |
"dream_label": "Dream note",
|
| 24 |
"dream_placeholder": "Write the dream while it is still foggy...\nExample: I kept missing an elevator. The button for floor 14 melted like wax.",
|
| 25 |
-
"mic_idle": "
|
| 26 |
"mic_unsupported": "This browser cannot transcribe voice here. You can still type the dream.",
|
| 27 |
"mic_permission": "Microphone permission was not granted. Allow recording and try again.",
|
| 28 |
-
"mic_listening": "
|
| 29 |
-
"
|
|
|
|
|
|
|
| 30 |
"mic_empty": "No speech detected. Tap again if you want to retry.",
|
|
|
|
|
|
|
| 31 |
"voice_label": "Voice note",
|
| 32 |
-
"voice_help": "Record or upload a short voice note. It is
|
| 33 |
"field_tip": "The desk looks for three anchors: a place, an object, and the question the dream left behind.",
|
| 34 |
"example_button": "elevator",
|
| 35 |
"example_button_2": "floor 14",
|
|
@@ -93,14 +97,18 @@ APP_COPY = {
|
|
| 93 |
"notice_error": "梦境问答台还没有收到片段。",
|
| 94 |
"dream_label": "梦境记录",
|
| 95 |
"dream_placeholder": "趁梦还带着雾气,先写下来...\n例如:我一直赶不上电梯,14 楼按钮像蜡一样融化。",
|
| 96 |
-
"mic_idle": "
|
| 97 |
"mic_unsupported": "这个浏览器暂时不能直接转写语音,你仍然可以手动输入梦境。",
|
| 98 |
"mic_permission": "没有获得麦克风权限。允许浏览器录音后可以再试一次。",
|
| 99 |
-
"mic_listening": "正在
|
| 100 |
-
"
|
|
|
|
|
|
|
| 101 |
"mic_empty": "没有检测到语音。想重试的话,再点一次麦克风。",
|
|
|
|
|
|
|
| 102 |
"voice_label": "语音片段",
|
| 103 |
-
"voice_help": "可以录音或上传一小段语音。点击继续后,ASR
|
| 104 |
"field_tip": "问讯室会优先寻找三个锚点:地点、物件、以及梦醒后留下的问题。",
|
| 105 |
"example_button": "电梯",
|
| 106 |
"example_button_2": "14 楼",
|
|
|
|
| 22 |
"notice_error": "Dream QA needs a dream fragment before it can continue.",
|
| 23 |
"dream_label": "Dream note",
|
| 24 |
"dream_placeholder": "Write the dream while it is still foggy...\nExample: I kept missing an elevator. The button for floor 14 melted like wax.",
|
| 25 |
+
"mic_idle": "Add a voice note",
|
| 26 |
"mic_unsupported": "This browser cannot transcribe voice here. You can still type the dream.",
|
| 27 |
"mic_permission": "Microphone permission was not granted. Allow recording and try again.",
|
| 28 |
+
"mic_listening": "Recording. Tap again to stop, or pause and MiMo ASR will transcribe.",
|
| 29 |
+
"mic_transcribing": "Transcribing with MiMo ASR...",
|
| 30 |
+
"mic_waking": "MiMo ASR is waking on Modal. This first pass can take a moment.",
|
| 31 |
+
"mic_done": "Added the ASR transcript to the dream note.",
|
| 32 |
"mic_empty": "No speech detected. Tap again if you want to retry.",
|
| 33 |
+
"mic_error": "Voice transcription failed. You can type this fragment instead.",
|
| 34 |
+
"mic_timeout": "MiMo ASR timed out. Tap the mic to try once more.",
|
| 35 |
"voice_label": "Voice note",
|
| 36 |
+
"voice_help": "Record or upload a short voice note. It is sent to MiMo ASR when you continue.",
|
| 37 |
"field_tip": "The desk looks for three anchors: a place, an object, and the question the dream left behind.",
|
| 38 |
"example_button": "elevator",
|
| 39 |
"example_button_2": "floor 14",
|
|
|
|
| 97 |
"notice_error": "梦境问答台还没有收到片段。",
|
| 98 |
"dream_label": "梦境记录",
|
| 99 |
"dream_placeholder": "趁梦还带着雾气,先写下来...\n例如:我一直赶不上电梯,14 楼按钮像蜡一样融化。",
|
| 100 |
+
"mic_idle": "添加语音片段",
|
| 101 |
"mic_unsupported": "这个浏览器暂时不能直接转写语音,你仍然可以手动输入梦境。",
|
| 102 |
"mic_permission": "没有获得麦克风权限。允许浏览器录音后可以再试一次。",
|
| 103 |
+
"mic_listening": "正在录音。再次点击可停止,停顿后会交给 MiMo ASR 转写。",
|
| 104 |
+
"mic_transcribing": "正在用 MiMo ASR 转写...",
|
| 105 |
+
"mic_waking": "Modal 上的 MiMo ASR 正在唤醒,首次转写可能需要等一下。",
|
| 106 |
+
"mic_done": "已把 ASR 转写加入梦境记录。",
|
| 107 |
"mic_empty": "没有检测到语音。想重试的话,再点一次麦克风。",
|
| 108 |
+
"mic_error": "语音转写失败。你也可以先手动输入这一段。",
|
| 109 |
+
"mic_timeout": "MiMo ASR 转写超时了。可以再点一次麦克风重试。",
|
| 110 |
"voice_label": "语音片段",
|
| 111 |
+
"voice_help": "可以录音或上传一小段语音。点击继续后,会直接发送给 MiMo ASR 转写。",
|
| 112 |
"field_tip": "问讯室会优先寻找三个锚点:地点、物件、以及梦醒后留下的问题。",
|
| 113 |
"example_button": "电梯",
|
| 114 |
"example_button_2": "14 楼",
|
dream_customs/ui/styles.py
CHANGED
|
@@ -441,7 +441,9 @@ body,
|
|
| 441 |
transform: translateY(-1px);
|
| 442 |
}
|
| 443 |
|
| 444 |
-
.dc-mic-button[data-mode="
|
|
|
|
|
|
|
| 445 |
animation: dc-mic-pulse 1.2s ease-in-out infinite;
|
| 446 |
background: var(--dc-teal) !important;
|
| 447 |
}
|
|
@@ -476,12 +478,18 @@ body,
|
|
| 476 |
width: 2px;
|
| 477 |
}
|
| 478 |
|
| 479 |
-
.dc-mic-button[data-mode="
|
| 480 |
-
.dc-mic-button[data-mode="
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
border-color: #fff9ee;
|
| 482 |
}
|
| 483 |
|
| 484 |
-
.dc-mic-button[data-mode="
|
|
|
|
|
|
|
| 485 |
background: #fff9ee;
|
| 486 |
}
|
| 487 |
|
|
@@ -500,7 +508,9 @@ body,
|
|
| 500 |
}
|
| 501 |
|
| 502 |
.dc-mic-control:hover .dc-mic-status,
|
| 503 |
-
.dc-mic-status[data-mode="
|
|
|
|
|
|
|
| 504 |
.dc-mic-status[data-mode="error"],
|
| 505 |
.dc-mic-status[data-mode="done"] {
|
| 506 |
opacity: 1;
|
|
|
|
| 441 |
transform: translateY(-1px);
|
| 442 |
}
|
| 443 |
|
| 444 |
+
.dc-mic-button[data-mode="recording"],
|
| 445 |
+
.dc-mic-button[data-mode="transcribing"],
|
| 446 |
+
.dc-mic-button[data-mode="waking"] {
|
| 447 |
animation: dc-mic-pulse 1.2s ease-in-out infinite;
|
| 448 |
background: var(--dc-teal) !important;
|
| 449 |
}
|
|
|
|
| 478 |
width: 2px;
|
| 479 |
}
|
| 480 |
|
| 481 |
+
.dc-mic-button[data-mode="recording"] .dc-mic-glyph,
|
| 482 |
+
.dc-mic-button[data-mode="recording"] .dc-mic-glyph::before,
|
| 483 |
+
.dc-mic-button[data-mode="transcribing"] .dc-mic-glyph,
|
| 484 |
+
.dc-mic-button[data-mode="transcribing"] .dc-mic-glyph::before,
|
| 485 |
+
.dc-mic-button[data-mode="waking"] .dc-mic-glyph,
|
| 486 |
+
.dc-mic-button[data-mode="waking"] .dc-mic-glyph::before {
|
| 487 |
border-color: #fff9ee;
|
| 488 |
}
|
| 489 |
|
| 490 |
+
.dc-mic-button[data-mode="recording"] .dc-mic-glyph::after,
|
| 491 |
+
.dc-mic-button[data-mode="transcribing"] .dc-mic-glyph::after,
|
| 492 |
+
.dc-mic-button[data-mode="waking"] .dc-mic-glyph::after {
|
| 493 |
background: #fff9ee;
|
| 494 |
}
|
| 495 |
|
|
|
|
| 508 |
}
|
| 509 |
|
| 510 |
.dc-mic-control:hover .dc-mic-status,
|
| 511 |
+
.dc-mic-status[data-mode="recording"],
|
| 512 |
+
.dc-mic-status[data-mode="transcribing"],
|
| 513 |
+
.dc-mic-status[data-mode="waking"],
|
| 514 |
.dc-mic-status[data-mode="error"],
|
| 515 |
.dc-mic-status[data-mode="done"] {
|
| 516 |
opacity: 1;
|
modal_backend/contracts.py
CHANGED
|
@@ -30,11 +30,11 @@ def normalize_text_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
| 30 |
except (TypeError, ValueError):
|
| 31 |
max_tokens = 700
|
| 32 |
max_tokens = max(64, min(max_tokens, 1200))
|
| 33 |
-
temperature = payload.get("temperature", 0.
|
| 34 |
try:
|
| 35 |
temperature = float(temperature)
|
| 36 |
except (TypeError, ValueError):
|
| 37 |
-
temperature = 0.
|
| 38 |
temperature = max(0.0, min(temperature, 0.7))
|
| 39 |
return {
|
| 40 |
"prompt": prompt,
|
|
|
|
| 30 |
except (TypeError, ValueError):
|
| 31 |
max_tokens = 700
|
| 32 |
max_tokens = max(64, min(max_tokens, 1200))
|
| 33 |
+
temperature = payload.get("temperature", 0.0)
|
| 34 |
try:
|
| 35 |
temperature = float(temperature)
|
| 36 |
except (TypeError, ValueError):
|
| 37 |
+
temperature = 0.0
|
| 38 |
temperature = max(0.0, min(temperature, 0.7))
|
| 39 |
return {
|
| 40 |
"prompt": prompt,
|
modal_backend/dream_customs_modal.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
import io
|
| 2 |
import json
|
| 3 |
import os
|
|
|
|
|
|
|
| 4 |
import tempfile
|
| 5 |
from typing import Any, Dict, Optional
|
| 6 |
|
|
@@ -20,7 +22,11 @@ from modal_backend.contracts import (
|
|
| 20 |
APP_NAME = "dream-customs-minicpm-backend"
|
| 21 |
TEXT_MODEL = "openbmb/MiniCPM5-1B"
|
| 22 |
VISION_MODEL = "openbmb/MiniCPM-V-4.6"
|
| 23 |
-
ASR_MODEL = "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
MINUTES = 60
|
| 25 |
|
| 26 |
app = modal.App(APP_NAME)
|
|
@@ -52,6 +58,34 @@ image = (
|
|
| 52 |
.add_local_dir("modal_backend", remote_path="/root/modal_backend")
|
| 53 |
)
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
secrets = [
|
| 56 |
modal.Secret.from_name(
|
| 57 |
"dream-customs-modal-secrets",
|
|
@@ -121,13 +155,54 @@ def _load_vision_pipe():
|
|
| 121 |
def _load_asr_pipe():
|
| 122 |
global _ASR_PIPE
|
| 123 |
if _ASR_PIPE is None:
|
| 124 |
-
from
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
)
|
| 132 |
return _ASR_PIPE
|
| 133 |
|
|
@@ -373,6 +448,7 @@ def health() -> Dict[str, str]:
|
|
| 373 |
"text_model": TEXT_MODEL,
|
| 374 |
"vision_model": VISION_MODEL,
|
| 375 |
"asr_model": ASR_MODEL,
|
|
|
|
| 376 |
}
|
| 377 |
|
| 378 |
|
|
@@ -467,9 +543,9 @@ async def vision(
|
|
| 467 |
|
| 468 |
|
| 469 |
@app.function(
|
| 470 |
-
image=
|
| 471 |
-
gpu="
|
| 472 |
-
timeout=
|
| 473 |
scaledown_window=5 * MINUTES,
|
| 474 |
volumes={"/root/.cache/huggingface": hf_cache},
|
| 475 |
secrets=secrets,
|
|
@@ -489,10 +565,10 @@ async def asr(
|
|
| 489 |
return _json_error(str(exc))
|
| 490 |
|
| 491 |
suffix = os.path.splitext(filename)[1] or ".wav"
|
| 492 |
-
|
| 493 |
with tempfile.NamedTemporaryFile(suffix=suffix) as temp_file:
|
| 494 |
temp_file.write(audio_bytes)
|
| 495 |
temp_file.flush()
|
| 496 |
-
|
| 497 |
-
transcript =
|
| 498 |
return {"status": "ok", "transcript": transcript, "response": transcript}
|
|
|
|
| 1 |
import io
|
| 2 |
import json
|
| 3 |
import os
|
| 4 |
+
import subprocess
|
| 5 |
+
import sys
|
| 6 |
import tempfile
|
| 7 |
from typing import Any, Dict, Optional
|
| 8 |
|
|
|
|
| 22 |
APP_NAME = "dream-customs-minicpm-backend"
|
| 23 |
TEXT_MODEL = "openbmb/MiniCPM5-1B"
|
| 24 |
VISION_MODEL = "openbmb/MiniCPM-V-4.6"
|
| 25 |
+
ASR_MODEL = "XiaomiMiMo/MiMo-V2.5-ASR"
|
| 26 |
+
ASR_TOKENIZER_MODEL = "XiaomiMiMo/MiMo-Audio-Tokenizer"
|
| 27 |
+
MIMO_ASR_REPO_DIR = "/opt/MiMo-V2.5-ASR"
|
| 28 |
+
MIMO_ASR_NESTED_REPO_DIR = "/opt/MiMo-V2.5-ASR/MiMo-V2.5-ASR"
|
| 29 |
+
MIMO_ASR_RUNTIME_REPO_DIR = "/tmp/MiMo-V2.5-ASR"
|
| 30 |
MINUTES = 60
|
| 31 |
|
| 32 |
app = modal.App(APP_NAME)
|
|
|
|
| 58 |
.add_local_dir("modal_backend", remote_path="/root/modal_backend")
|
| 59 |
)
|
| 60 |
|
| 61 |
+
asr_image = (
|
| 62 |
+
modal.Image.from_registry("nvidia/cuda:12.4.1-devel-ubuntu22.04", add_python="3.12")
|
| 63 |
+
.apt_install("build-essential", "ffmpeg", "git", "ninja-build")
|
| 64 |
+
.pip_install(
|
| 65 |
+
"accelerate>=1.9.0",
|
| 66 |
+
"fastapi[standard]>=0.116.1",
|
| 67 |
+
"huggingface-hub",
|
| 68 |
+
"librosa>=0.11.0",
|
| 69 |
+
"pydantic>=2.11.7",
|
| 70 |
+
"scipy>=1.16.1",
|
| 71 |
+
"torch==2.6.0",
|
| 72 |
+
"torchaudio==2.6.0",
|
| 73 |
+
"transformers==4.49.0",
|
| 74 |
+
"triton==3.2.0",
|
| 75 |
+
"uvicorn>=0.35.0",
|
| 76 |
+
"zhon==2.1.1",
|
| 77 |
+
)
|
| 78 |
+
.run_commands(
|
| 79 |
+
"pip install wheel && "
|
| 80 |
+
"CUDA_HOME=/usr/local/cuda pip install flash-attn==2.7.4.post1 --no-build-isolation"
|
| 81 |
+
)
|
| 82 |
+
.run_commands(
|
| 83 |
+
"git clone --depth 1 https://github.com/XiaomiMiMo/MiMo-V2.5-ASR.git "
|
| 84 |
+
f"{MIMO_ASR_REPO_DIR} && test -d {MIMO_ASR_REPO_DIR}/src"
|
| 85 |
+
)
|
| 86 |
+
.add_local_dir("modal_backend", remote_path="/root/modal_backend")
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
secrets = [
|
| 90 |
modal.Secret.from_name(
|
| 91 |
"dream-customs-modal-secrets",
|
|
|
|
| 155 |
def _load_asr_pipe():
|
| 156 |
global _ASR_PIPE
|
| 157 |
if _ASR_PIPE is None:
|
| 158 |
+
from huggingface_hub import snapshot_download
|
| 159 |
|
| 160 |
+
repo_candidates = (
|
| 161 |
+
MIMO_ASR_REPO_DIR,
|
| 162 |
+
MIMO_ASR_NESTED_REPO_DIR,
|
| 163 |
+
MIMO_ASR_RUNTIME_REPO_DIR,
|
| 164 |
+
)
|
| 165 |
+
source_dir = ""
|
| 166 |
+
for repo_dir in repo_candidates:
|
| 167 |
+
if os.path.isdir(os.path.join(repo_dir, "src")) and repo_dir not in sys.path:
|
| 168 |
+
sys.path.insert(0, repo_dir)
|
| 169 |
+
source_dir = repo_dir
|
| 170 |
+
break
|
| 171 |
+
if os.path.isdir(os.path.join(repo_dir, "src")):
|
| 172 |
+
source_dir = repo_dir
|
| 173 |
+
break
|
| 174 |
+
if not source_dir:
|
| 175 |
+
subprocess.run(
|
| 176 |
+
[
|
| 177 |
+
"git",
|
| 178 |
+
"clone",
|
| 179 |
+
"--depth",
|
| 180 |
+
"1",
|
| 181 |
+
"https://github.com/XiaomiMiMo/MiMo-V2.5-ASR.git",
|
| 182 |
+
MIMO_ASR_RUNTIME_REPO_DIR,
|
| 183 |
+
],
|
| 184 |
+
check=True,
|
| 185 |
+
)
|
| 186 |
+
if not os.path.isdir(os.path.join(MIMO_ASR_RUNTIME_REPO_DIR, "src")):
|
| 187 |
+
raise RuntimeError("MiMo-V2.5-ASR source checkout is missing in Modal.")
|
| 188 |
+
sys.path.insert(0, MIMO_ASR_RUNTIME_REPO_DIR)
|
| 189 |
+
from src.mimo_audio.mimo_audio import MimoAudio
|
| 190 |
+
|
| 191 |
+
model_path = os.getenv("DREAM_CUSTOMS_ASR_MODEL_PATH", "").strip()
|
| 192 |
+
if not model_path:
|
| 193 |
+
model_path = snapshot_download(
|
| 194 |
+
os.getenv("DREAM_CUSTOMS_ASR_MODEL", ASR_MODEL),
|
| 195 |
+
token=os.getenv("HF_TOKEN") or None,
|
| 196 |
+
)
|
| 197 |
+
tokenizer_path = os.getenv("DREAM_CUSTOMS_ASR_TOKENIZER_PATH", "").strip()
|
| 198 |
+
if not tokenizer_path:
|
| 199 |
+
tokenizer_path = snapshot_download(
|
| 200 |
+
os.getenv("DREAM_CUSTOMS_ASR_TOKENIZER_MODEL", ASR_TOKENIZER_MODEL),
|
| 201 |
+
token=os.getenv("HF_TOKEN") or None,
|
| 202 |
+
)
|
| 203 |
+
_ASR_PIPE = MimoAudio(
|
| 204 |
+
model_path=model_path,
|
| 205 |
+
mimo_audio_tokenizer_path=tokenizer_path,
|
| 206 |
)
|
| 207 |
return _ASR_PIPE
|
| 208 |
|
|
|
|
| 448 |
"text_model": TEXT_MODEL,
|
| 449 |
"vision_model": VISION_MODEL,
|
| 450 |
"asr_model": ASR_MODEL,
|
| 451 |
+
"asr_tokenizer_model": ASR_TOKENIZER_MODEL,
|
| 452 |
}
|
| 453 |
|
| 454 |
|
|
|
|
| 543 |
|
| 544 |
|
| 545 |
@app.function(
|
| 546 |
+
image=asr_image,
|
| 547 |
+
gpu="A100",
|
| 548 |
+
timeout=20 * MINUTES,
|
| 549 |
scaledown_window=5 * MINUTES,
|
| 550 |
volumes={"/root/.cache/huggingface": hf_cache},
|
| 551 |
secrets=secrets,
|
|
|
|
| 565 |
return _json_error(str(exc))
|
| 566 |
|
| 567 |
suffix = os.path.splitext(filename)[1] or ".wav"
|
| 568 |
+
model = _load_asr_pipe()
|
| 569 |
with tempfile.NamedTemporaryFile(suffix=suffix) as temp_file:
|
| 570 |
temp_file.write(audio_bytes)
|
| 571 |
temp_file.flush()
|
| 572 |
+
transcript = model.asr_sft(temp_file.name)
|
| 573 |
+
transcript = str(transcript or "").strip()
|
| 574 |
return {"status": "ok", "transcript": transcript, "response": transcript}
|
scripts/local_space_mirror.py
CHANGED
|
@@ -6,7 +6,10 @@ from __future__ import annotations
|
|
| 6 |
import argparse
|
| 7 |
import json
|
| 8 |
import os
|
|
|
|
|
|
|
| 9 |
import sys
|
|
|
|
| 10 |
from pathlib import Path
|
| 11 |
|
| 12 |
ROOT = Path(__file__).resolve().parents[1]
|
|
@@ -22,6 +25,10 @@ from dream_customs.runtime_env import (
|
|
| 22 |
)
|
| 23 |
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
def _repo_root_on_path() -> None:
|
| 26 |
root = str(ROOT)
|
| 27 |
if root not in sys.path:
|
|
@@ -53,8 +60,12 @@ def mirror_manifest(host: str, port: int) -> dict:
|
|
| 53 |
|
| 54 |
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
| 55 |
parser = argparse.ArgumentParser(description=__doc__)
|
| 56 |
-
parser.add_argument("--host", default=os.getenv("GRADIO_SERVER_NAME",
|
| 57 |
-
parser.add_argument(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
parser.add_argument("--share", action="store_true", help="Create a public Gradio share link.")
|
| 59 |
parser.add_argument(
|
| 60 |
"--manifest-only",
|
|
@@ -75,9 +86,81 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
| 75 |
action="store_true",
|
| 76 |
help="Do not auto-load the default local runtime env JSON.",
|
| 77 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
return parser.parse_args(argv)
|
| 79 |
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
def main(argv: list[str] | None = None) -> int:
|
| 82 |
args = parse_args(argv)
|
| 83 |
os.environ["GRADIO_SERVER_NAME"] = args.host
|
|
@@ -91,6 +174,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
| 91 |
|
| 92 |
manifest = mirror_manifest(args.host, args.port)
|
| 93 |
manifest["runtime_env_json"] = runtime_env
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
print(json.dumps(manifest, ensure_ascii=False, indent=2), flush=True)
|
| 95 |
if args.manifest_only:
|
| 96 |
return 0
|
|
|
|
| 6 |
import argparse
|
| 7 |
import json
|
| 8 |
import os
|
| 9 |
+
import signal
|
| 10 |
+
import subprocess
|
| 11 |
import sys
|
| 12 |
+
import time
|
| 13 |
from pathlib import Path
|
| 14 |
|
| 15 |
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
| 25 |
)
|
| 26 |
|
| 27 |
|
| 28 |
+
DEFAULT_LOCAL_HOST = "127.0.0.1"
|
| 29 |
+
DEFAULT_LOCAL_PORT = 7862
|
| 30 |
+
|
| 31 |
+
|
| 32 |
def _repo_root_on_path() -> None:
|
| 33 |
root = str(ROOT)
|
| 34 |
if root not in sys.path:
|
|
|
|
| 60 |
|
| 61 |
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
| 62 |
parser = argparse.ArgumentParser(description=__doc__)
|
| 63 |
+
parser.add_argument("--host", default=os.getenv("GRADIO_SERVER_NAME", DEFAULT_LOCAL_HOST))
|
| 64 |
+
parser.add_argument(
|
| 65 |
+
"--port",
|
| 66 |
+
type=int,
|
| 67 |
+
default=int(os.getenv("GRADIO_SERVER_PORT", str(DEFAULT_LOCAL_PORT))),
|
| 68 |
+
)
|
| 69 |
parser.add_argument("--share", action="store_true", help="Create a public Gradio share link.")
|
| 70 |
parser.add_argument(
|
| 71 |
"--manifest-only",
|
|
|
|
| 86 |
action="store_true",
|
| 87 |
help="Do not auto-load the default local runtime env JSON.",
|
| 88 |
)
|
| 89 |
+
parser.add_argument(
|
| 90 |
+
"--no-replace",
|
| 91 |
+
action="store_true",
|
| 92 |
+
help="Do not stop an older Dream QA local app that is already listening on the target port.",
|
| 93 |
+
)
|
| 94 |
return parser.parse_args(argv)
|
| 95 |
|
| 96 |
|
| 97 |
+
def _run_text(argv: list[str]) -> str:
|
| 98 |
+
try:
|
| 99 |
+
result = subprocess.run(argv, check=False, capture_output=True, text=True)
|
| 100 |
+
except FileNotFoundError:
|
| 101 |
+
return ""
|
| 102 |
+
if result.returncode != 0:
|
| 103 |
+
return ""
|
| 104 |
+
return result.stdout.strip()
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _listener_pids(port: int) -> list[int]:
|
| 108 |
+
output = _run_text(["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"])
|
| 109 |
+
pids: list[int] = []
|
| 110 |
+
for line in output.splitlines():
|
| 111 |
+
try:
|
| 112 |
+
pids.append(int(line.strip()))
|
| 113 |
+
except ValueError:
|
| 114 |
+
continue
|
| 115 |
+
return pids
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _process_command(pid: int) -> str:
|
| 119 |
+
return _run_text(["ps", "-p", str(pid), "-o", "command="])
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _process_cwd(pid: int) -> Path | None:
|
| 123 |
+
output = _run_text(["lsof", "-a", "-p", str(pid), "-d", "cwd", "-Fn"])
|
| 124 |
+
for line in output.splitlines():
|
| 125 |
+
if line.startswith("n"):
|
| 126 |
+
return Path(line[1:]).resolve()
|
| 127 |
+
return None
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _is_repo_gradio_command(command: str) -> bool:
|
| 131 |
+
return "scripts/local_space_mirror.py" in command or command.endswith(" app.py")
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _is_repo_local_gradio_process(pid: int) -> bool:
|
| 135 |
+
cwd = _process_cwd(pid)
|
| 136 |
+
command = _process_command(pid)
|
| 137 |
+
return cwd == ROOT.resolve() and _is_repo_gradio_command(command)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _wait_until_port_is_free(port: int, stopped_pids: list[int]) -> None:
|
| 141 |
+
deadline = time.time() + 4
|
| 142 |
+
while time.time() < deadline:
|
| 143 |
+
remaining = [pid for pid in _listener_pids(port) if pid in stopped_pids]
|
| 144 |
+
if not remaining:
|
| 145 |
+
return
|
| 146 |
+
time.sleep(0.2)
|
| 147 |
+
raise RuntimeError(
|
| 148 |
+
f"Stopped local Gradio process(es) {stopped_pids}, but port {port} is still busy."
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def stop_previous_local_app(port: int) -> list[int]:
|
| 153 |
+
stopped_pids: list[int] = []
|
| 154 |
+
for pid in _listener_pids(port):
|
| 155 |
+
if pid == os.getpid() or not _is_repo_local_gradio_process(pid):
|
| 156 |
+
continue
|
| 157 |
+
os.kill(pid, signal.SIGTERM)
|
| 158 |
+
stopped_pids.append(pid)
|
| 159 |
+
if stopped_pids:
|
| 160 |
+
_wait_until_port_is_free(port, stopped_pids)
|
| 161 |
+
return stopped_pids
|
| 162 |
+
|
| 163 |
+
|
| 164 |
def main(argv: list[str] | None = None) -> int:
|
| 165 |
args = parse_args(argv)
|
| 166 |
os.environ["GRADIO_SERVER_NAME"] = args.host
|
|
|
|
| 174 |
|
| 175 |
manifest = mirror_manifest(args.host, args.port)
|
| 176 |
manifest["runtime_env_json"] = runtime_env
|
| 177 |
+
if args.manifest_only or args.no_replace:
|
| 178 |
+
manifest["replaced_local_pids"] = []
|
| 179 |
+
else:
|
| 180 |
+
manifest["replaced_local_pids"] = stop_previous_local_app(args.port)
|
| 181 |
print(json.dumps(manifest, ensure_ascii=False, indent=2), flush=True)
|
| 182 |
if args.manifest_only:
|
| 183 |
return 0
|
tests/test_app_logic.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import json
|
|
|
|
| 2 |
|
| 3 |
from dream_customs.app_logic import (
|
| 4 |
_clients,
|
|
@@ -148,3 +149,22 @@ def test_asr_endpoint_derives_from_modal_text_endpoint_when_secret_is_missing():
|
|
| 148 |
)
|
| 149 |
|
| 150 |
assert settings["asr_endpoint"] == "https://workspace--dream-customs-minicpm-backend-asr.modal.run"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import json
|
| 2 |
+
import urllib.error
|
| 3 |
|
| 4 |
from dream_customs.app_logic import (
|
| 5 |
_clients,
|
|
|
|
| 149 |
)
|
| 150 |
|
| 151 |
assert settings["asr_endpoint"] == "https://workspace--dream-customs-minicpm-backend-asr.modal.run"
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def test_hosted_asr_can_disable_demo_fallback_for_browser_voice(monkeypatch, tmp_path):
|
| 155 |
+
audio_path = tmp_path / "voice.wav"
|
| 156 |
+
audio_path.write_bytes(b"not a real wav but enough to exercise upload encoding")
|
| 157 |
+
|
| 158 |
+
def raise_url_error(*_args, **_kwargs):
|
| 159 |
+
raise urllib.error.URLError("modal unavailable")
|
| 160 |
+
|
| 161 |
+
monkeypatch.setattr("dream_customs.models.urllib.request.urlopen", raise_url_error)
|
| 162 |
+
client = HostedASRClient(
|
| 163 |
+
endpoint="https://example.test/asr",
|
| 164 |
+
token="secret-token",
|
| 165 |
+
timeout=3,
|
| 166 |
+
fallback_enabled=False,
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
assert client.transcribe(str(audio_path)) == ""
|
| 170 |
+
assert "URLError" in client.last_error
|
tests/test_local_space_mirror.py
CHANGED
|
@@ -1,9 +1,16 @@
|
|
| 1 |
import json
|
| 2 |
import os
|
|
|
|
| 3 |
import subprocess
|
| 4 |
import sys
|
| 5 |
|
| 6 |
-
from scripts.local_space_mirror import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
from scripts.smoke_local_space_mirror import inspect_config
|
| 8 |
|
| 9 |
|
|
@@ -15,7 +22,7 @@ def test_local_space_mirror_manifest_matches_space_entrypoint(monkeypatch):
|
|
| 15 |
"DREAM_CUSTOMS_HOSTED_TOKEN",
|
| 16 |
]:
|
| 17 |
monkeypatch.delenv(key, raising=False)
|
| 18 |
-
manifest = mirror_manifest("127.0.0.1",
|
| 19 |
|
| 20 |
assert manifest["mode"] == "local-space-mirror"
|
| 21 |
assert manifest["space_id"] == "build-small-hackathon/dream-customs"
|
|
@@ -35,6 +42,68 @@ def test_local_space_mirror_manifest_matches_space_entrypoint(monkeypatch):
|
|
| 35 |
assert "secret" not in serialized_env
|
| 36 |
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
def test_local_space_mirror_loads_runtime_env_json_without_printing_values(tmp_path, monkeypatch):
|
| 39 |
for key in [
|
| 40 |
"DREAM_CUSTOMS_TEXT_ENDPOINT",
|
|
|
|
| 1 |
import json
|
| 2 |
import os
|
| 3 |
+
import signal
|
| 4 |
import subprocess
|
| 5 |
import sys
|
| 6 |
|
| 7 |
+
from scripts.local_space_mirror import (
|
| 8 |
+
DEFAULT_LOCAL_PORT,
|
| 9 |
+
_is_repo_gradio_command,
|
| 10 |
+
load_runtime_env_json,
|
| 11 |
+
mirror_manifest,
|
| 12 |
+
stop_previous_local_app,
|
| 13 |
+
)
|
| 14 |
from scripts.smoke_local_space_mirror import inspect_config
|
| 15 |
|
| 16 |
|
|
|
|
| 22 |
"DREAM_CUSTOMS_HOSTED_TOKEN",
|
| 23 |
]:
|
| 24 |
monkeypatch.delenv(key, raising=False)
|
| 25 |
+
manifest = mirror_manifest("127.0.0.1", DEFAULT_LOCAL_PORT)
|
| 26 |
|
| 27 |
assert manifest["mode"] == "local-space-mirror"
|
| 28 |
assert manifest["space_id"] == "build-small-hackathon/dream-customs"
|
|
|
|
| 42 |
assert "secret" not in serialized_env
|
| 43 |
|
| 44 |
|
| 45 |
+
def test_app_uses_stable_local_port_without_changing_space_default():
|
| 46 |
+
env = os.environ.copy()
|
| 47 |
+
env.pop("PYTEST_CURRENT_TEST", None)
|
| 48 |
+
for key in ["GRADIO_SERVER_PORT", "PORT", "SPACE_HOST", "SPACE_ID"]:
|
| 49 |
+
env.pop(key, None)
|
| 50 |
+
env["GRADIO_ANALYTICS_ENABLED"] = "False"
|
| 51 |
+
|
| 52 |
+
local_result = subprocess.run(
|
| 53 |
+
[sys.executable, "-c", "import app; print(app._default_server_port())"],
|
| 54 |
+
check=True,
|
| 55 |
+
capture_output=True,
|
| 56 |
+
text=True,
|
| 57 |
+
env=env,
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
env["SPACE_ID"] = "build-small-hackathon/dream-customs"
|
| 61 |
+
space_result = subprocess.run(
|
| 62 |
+
[sys.executable, "-c", "import app; print(app._default_server_port())"],
|
| 63 |
+
check=True,
|
| 64 |
+
capture_output=True,
|
| 65 |
+
text=True,
|
| 66 |
+
env=env,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
env["GRADIO_SERVER_PORT"] = "7877"
|
| 70 |
+
configured_result = subprocess.run(
|
| 71 |
+
[sys.executable, "-c", "import app; print(app._default_server_port())"],
|
| 72 |
+
check=True,
|
| 73 |
+
capture_output=True,
|
| 74 |
+
text=True,
|
| 75 |
+
env=env,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
assert local_result.stdout.strip() == "7862"
|
| 79 |
+
assert space_result.stdout.strip() == "7860"
|
| 80 |
+
assert configured_result.stdout.strip() == "7877"
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_local_space_mirror_only_replaces_repo_gradio_commands():
|
| 84 |
+
assert _is_repo_gradio_command("/usr/bin/python scripts/local_space_mirror.py")
|
| 85 |
+
assert _is_repo_gradio_command("/usr/bin/python app.py")
|
| 86 |
+
assert not _is_repo_gradio_command("/usr/bin/python other_app.py")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_local_space_mirror_stops_only_matching_repo_processes(monkeypatch):
|
| 90 |
+
killed: list[tuple[int, signal.Signals]] = []
|
| 91 |
+
|
| 92 |
+
monkeypatch.setattr("scripts.local_space_mirror._listener_pids", lambda port: [111, 222])
|
| 93 |
+
monkeypatch.setattr(
|
| 94 |
+
"scripts.local_space_mirror._is_repo_local_gradio_process",
|
| 95 |
+
lambda pid: pid == 111,
|
| 96 |
+
)
|
| 97 |
+
monkeypatch.setattr(
|
| 98 |
+
"scripts.local_space_mirror.os.kill",
|
| 99 |
+
lambda pid, sig: killed.append((pid, sig)),
|
| 100 |
+
)
|
| 101 |
+
monkeypatch.setattr("scripts.local_space_mirror._wait_until_port_is_free", lambda port, pids: None)
|
| 102 |
+
|
| 103 |
+
assert stop_previous_local_app(DEFAULT_LOCAL_PORT) == [111]
|
| 104 |
+
assert killed == [(111, signal.SIGTERM)]
|
| 105 |
+
|
| 106 |
+
|
| 107 |
def test_local_space_mirror_loads_runtime_env_json_without_printing_values(tmp_path, monkeypatch):
|
| 108 |
for key in [
|
| 109 |
"DREAM_CUSTOMS_TEXT_ENDPOINT",
|
tests/test_modal_contract.py
CHANGED
|
@@ -14,7 +14,7 @@ def test_normalize_text_payload_accepts_prompt():
|
|
| 14 |
payload = normalize_text_payload({"prompt": "Return JSON.", "max_tokens": 123})
|
| 15 |
assert payload["prompt"] == "Return JSON."
|
| 16 |
assert payload["max_tokens"] == 123
|
| 17 |
-
assert payload["temperature"] == 0.
|
| 18 |
|
| 19 |
|
| 20 |
def test_normalize_text_payload_accepts_openai_style_messages():
|
|
|
|
| 14 |
payload = normalize_text_payload({"prompt": "Return JSON.", "max_tokens": 123})
|
| 15 |
assert payload["prompt"] == "Return JSON."
|
| 16 |
assert payload["max_tokens"] == 123
|
| 17 |
+
assert payload["temperature"] == 0.0
|
| 18 |
|
| 19 |
|
| 20 |
def test_normalize_text_payload_accepts_openai_style_messages():
|
tests/test_ollama_models.py
CHANGED
|
@@ -143,6 +143,22 @@ def test_hosted_text_client_parses_common_response_shape():
|
|
| 143 |
assert negotiation["questions"] == ["What does it ask?"]
|
| 144 |
|
| 145 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
def test_hosted_text_client_parses_model_led_brief():
|
| 147 |
class StubHostedBriefClient(HostedMiniCPMTextClient):
|
| 148 |
def _post_json(self, prompt, max_tokens=700):
|
|
|
|
| 143 |
assert negotiation["questions"] == ["What does it ask?"]
|
| 144 |
|
| 145 |
|
| 146 |
+
def test_hosted_text_client_extracts_question_text_from_object_list():
|
| 147 |
+
client = StubHostedTextClient(
|
| 148 |
+
{
|
| 149 |
+
"response": (
|
| 150 |
+
'{"visitor_name":"小水盆",'
|
| 151 |
+
'"questions":[{"type":"1","question":"你当时是怎么被泼水的?"}],'
|
| 152 |
+
'"tone_note":"先贴近用户自己的场景。"}'
|
| 153 |
+
)
|
| 154 |
+
}
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
negotiation = client.generate_negotiation("dream")
|
| 158 |
+
|
| 159 |
+
assert negotiation["questions"] == ["你当时是怎么被泼水的?"]
|
| 160 |
+
|
| 161 |
+
|
| 162 |
def test_hosted_text_client_parses_model_led_brief():
|
| 163 |
class StubHostedBriefClient(HostedMiniCPMTextClient):
|
| 164 |
def _post_json(self, prompt, max_tokens=700):
|
tests/test_pipeline.py
CHANGED
|
@@ -93,6 +93,122 @@ def test_elevator_floor14_demo_anchors_are_clean_for_question_stage():
|
|
| 93 |
assert "for floor" not in state.dream_anchors
|
| 94 |
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
def test_generate_pact_returns_card_and_html():
|
| 97 |
intake = build_intake(dream_text="I missed an elevator.", mood="anxious")
|
| 98 |
card, html = generate_pact(intake, "I want a small start.", FakeTextClient())
|
|
|
|
| 93 |
assert "for floor" not in state.dream_anchors
|
| 94 |
|
| 95 |
|
| 96 |
+
def test_zh_company_nap_splashed_water_keeps_real_anchors():
|
| 97 |
+
session = add_evidence(
|
| 98 |
+
create_session(language="zh"),
|
| 99 |
+
dream_text="我在公司午觉的时候被人泼了一盆水。",
|
| 100 |
+
mood="",
|
| 101 |
+
vision_client=FakeVisionClient(),
|
| 102 |
+
asr_client=FakeASRClient(),
|
| 103 |
+
language="zh",
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
assert any("公司" in anchor for anchor in session.qa_state.dream_anchors)
|
| 107 |
+
assert any("午觉" in anchor or "午休" in anchor for anchor in session.qa_state.dream_anchors)
|
| 108 |
+
assert any("泼" in anchor or "水" in anchor for anchor in session.qa_state.dream_anchors)
|
| 109 |
+
|
| 110 |
+
session = ask_questions(session, FakeTextClient(), language="zh")
|
| 111 |
+
first_response = session.question_history[0]
|
| 112 |
+
|
| 113 |
+
assert "公司" in first_response or "午觉" in first_response or "泼" in first_response
|
| 114 |
+
assert "夜晚的海" not in first_response
|
| 115 |
+
assert "海浪" not in first_response
|
| 116 |
+
assert "月牙" not in first_response
|
| 117 |
+
assert "小人" not in first_response
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_zh_plain_water_fragment_does_not_invent_sea_scene():
|
| 121 |
+
session = add_evidence(
|
| 122 |
+
create_session(language="zh"),
|
| 123 |
+
dream_text="水",
|
| 124 |
+
mood="",
|
| 125 |
+
vision_client=FakeVisionClient(),
|
| 126 |
+
asr_client=FakeASRClient(),
|
| 127 |
+
language="zh",
|
| 128 |
+
)
|
| 129 |
+
session = ask_questions(session, FakeTextClient(), language="zh")
|
| 130 |
+
|
| 131 |
+
first_response = session.question_history[0]
|
| 132 |
+
|
| 133 |
+
assert "水" in first_response
|
| 134 |
+
assert "夜晚的海" not in first_response
|
| 135 |
+
assert "海浪" not in first_response
|
| 136 |
+
assert "月牙" not in first_response
|
| 137 |
+
assert "小人" not in first_response
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def test_today_tip_removes_unsupported_clinical_frame_for_company_water_dream():
|
| 141 |
+
class ClinicalHostedTextClient:
|
| 142 |
+
def generate_today_tip(self, prompt):
|
| 143 |
+
return TodayTipCard(
|
| 144 |
+
dream_summary="你梦见在公司午觉时被人泼水。",
|
| 145 |
+
main_question="这个梦里的水可能在提醒我什么?",
|
| 146 |
+
dream_anchors=["水"],
|
| 147 |
+
followup_questions=[],
|
| 148 |
+
user_answers=[],
|
| 149 |
+
interpretation="「被人泼水」可能是睡眠剥夺或压力过大的信号。",
|
| 150 |
+
today_tip="围绕「公司午觉」留意睡眠问题,必要时寻求专业帮助。",
|
| 151 |
+
tiny_action="把一杯水放在桌上,提醒自己尽快寻求专业帮助。",
|
| 152 |
+
caring_note="如果你持续焦虑或有睡眠问题,建议寻求专业帮助。",
|
| 153 |
+
safety_note="建议寻求专业帮助。",
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
intake = build_intake(dream_text="我在公司午觉的时候被人泼了一盆水。")
|
| 157 |
+
|
| 158 |
+
card = generate_today_tip(intake, "", ClinicalHostedTextClient(), language="zh")
|
| 159 |
+
combined = card.to_plain_text()
|
| 160 |
+
|
| 161 |
+
assert any("公司" in anchor for anchor in card.dream_anchors)
|
| 162 |
+
assert any("午觉" in anchor or "午休" in anchor for anchor in card.dream_anchors)
|
| 163 |
+
assert any("泼" in anchor or "水" in anchor for anchor in card.dream_anchors)
|
| 164 |
+
assert "睡眠剥夺" not in combined
|
| 165 |
+
assert "压力过大" not in combined
|
| 166 |
+
assert "睡眠问题" not in combined
|
| 167 |
+
assert "专业帮助" not in combined
|
| 168 |
+
assert card.safety_note == ""
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def test_today_tip_removes_unsupported_emotion_and_generic_wellness_for_company_water_dream():
|
| 172 |
+
class OverreachingHostedTextClient:
|
| 173 |
+
def generate_today_tip(self, prompt):
|
| 174 |
+
return TodayTipCard(
|
| 175 |
+
dream_summary="你梦见在公司午觉时被人泼水。",
|
| 176 |
+
main_question="这个梦里的水可能在提醒我什么?",
|
| 177 |
+
dream_anchors=["公司午觉", "被人泼水"],
|
| 178 |
+
followup_questions=["在「公司、午觉、被人泼水」里,醒来后最强烈的感受是什么?"],
|
| 179 |
+
user_answers=["用户选择跳过这个追问。"],
|
| 180 |
+
interpretation=(
|
| 181 |
+
"你选择跳过这个追问。梦中的公司可能是在提醒你注意工作与休息的平衡,"
|
| 182 |
+
"或者暗示你今天需要处理一些工作压力。午觉时被泼水可能象征着突如其来的挑战。"
|
| 183 |
+
),
|
| 184 |
+
today_tip="明天上班前,先喝一杯温水,或者听一首轻松的音乐。",
|
| 185 |
+
tiny_action="把一杯温水放在桌上提醒自己保持积极。",
|
| 186 |
+
caring_note="如果你感到害怕或孤独,不必过度自责。保持积极心态,明天会更好。",
|
| 187 |
+
safety_note="",
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
intake = build_intake(dream_text="我在公司午觉的时候被人泼了一盆水。")
|
| 191 |
+
|
| 192 |
+
card = generate_today_tip(
|
| 193 |
+
intake,
|
| 194 |
+
"用户选择跳过这个追问。",
|
| 195 |
+
OverreachingHostedTextClient(),
|
| 196 |
+
language="zh",
|
| 197 |
+
followup_questions=["在「公司、午觉、被人泼水」里,醒来后最强烈的感受是什么?"],
|
| 198 |
+
)
|
| 199 |
+
combined = card.to_plain_text()
|
| 200 |
+
|
| 201 |
+
assert "公司" in combined
|
| 202 |
+
assert "被人泼水" in combined or "被人泼了一盆水" in combined
|
| 203 |
+
assert "工作压力" not in combined
|
| 204 |
+
assert "害怕" not in combined
|
| 205 |
+
assert "孤独" not in combined
|
| 206 |
+
assert "保持积极" not in combined
|
| 207 |
+
assert "明天会更好" not in combined
|
| 208 |
+
assert "温水" not in combined
|
| 209 |
+
assert "轻松的音乐" not in combined
|
| 210 |
+
|
| 211 |
+
|
| 212 |
def test_generate_pact_returns_card_and_html():
|
| 213 |
intake = build_intake(dream_text="I missed an elevator.", mood="anxious")
|
| 214 |
card, html = generate_pact(intake, "I want a small start.", FakeTextClient())
|
tests/test_ui_actions.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import json
|
| 2 |
import inspect
|
| 3 |
from datetime import date
|
|
|
|
| 4 |
|
| 5 |
from dream_customs.ui.actions import (
|
| 6 |
answer_to_card_action,
|
|
@@ -34,22 +35,40 @@ def test_runtime_settings_are_collapsed_for_public_flow():
|
|
| 34 |
assert 'with gr.Accordion(initial_copy["debug_title"], open=False' in flow_column_source
|
| 35 |
|
| 36 |
|
| 37 |
-
def
|
| 38 |
source = inspect.getsource(ui_app.build_demo)
|
| 39 |
app_source = inspect.getsource(ui_app)
|
| 40 |
|
| 41 |
assert "audio_input = gr.Audio(" in source
|
| 42 |
-
assert 'sources=["upload"]' in source
|
| 43 |
-
assert 'sources=["microphone", "upload"]' not in source
|
| 44 |
-
assert 'type="filepath"' in source
|
| 45 |
assert "visible=False" in source
|
|
|
|
| 46 |
assert "_make_media_api_info_client_safe(audio_input)" in source
|
| 47 |
assert "mic_html = gr.HTML(_mic_html(DEFAULT_LANGUAGE))" in source
|
| 48 |
assert "audio_input = gr.State(None)" not in source
|
| 49 |
assert 'value=DEFAULT_ASR_BACKEND' in source
|
| 50 |
-
assert 'data-
|
| 51 |
-
assert "
|
| 52 |
-
assert "window.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
def test_image_upload_is_composer_plus_drawer():
|
|
|
|
| 1 |
import json
|
| 2 |
import inspect
|
| 3 |
from datetime import date
|
| 4 |
+
from pathlib import Path
|
| 5 |
|
| 6 |
from dream_customs.ui.actions import (
|
| 7 |
answer_to_card_action,
|
|
|
|
| 35 |
assert 'with gr.Accordion(initial_copy["debug_title"], open=False' in flow_column_source
|
| 36 |
|
| 37 |
|
| 38 |
+
def test_voice_input_records_audio_to_modal_asr_without_browser_speech_recognition():
|
| 39 |
source = inspect.getsource(ui_app.build_demo)
|
| 40 |
app_source = inspect.getsource(ui_app)
|
| 41 |
|
| 42 |
assert "audio_input = gr.Audio(" in source
|
|
|
|
|
|
|
|
|
|
| 43 |
assert "visible=False" in source
|
| 44 |
+
assert 'type="filepath"' in source
|
| 45 |
assert "_make_media_api_info_client_safe(audio_input)" in source
|
| 46 |
assert "mic_html = gr.HTML(_mic_html(DEFAULT_LANGUAGE))" in source
|
| 47 |
assert "audio_input = gr.State(None)" not in source
|
| 48 |
assert 'value=DEFAULT_ASR_BACKEND' in source
|
| 49 |
+
assert 'data-open="' in ui_app._mic_html("en")
|
| 50 |
+
assert 'aria-expanded="false"' in ui_app._mic_html("en")
|
| 51 |
+
assert "window.SpeechRecognition" not in app_source
|
| 52 |
+
assert "webkitSpeechRecognition" not in app_source
|
| 53 |
+
assert "recognition.lang" not in app_source
|
| 54 |
+
assert "MediaRecorder" in app_source
|
| 55 |
+
assert 'fetch("/dream-asr"' in app_source
|
| 56 |
+
assert "AbortController" in app_source
|
| 57 |
+
assert "asrRequestTimeout" in app_source
|
| 58 |
+
assert "waking" in app_source
|
| 59 |
+
assert "appendTranscript" in app_source
|
| 60 |
+
assert "DREAM_CUSTOMS_HOSTED_TOKEN" not in app_source
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_browser_voice_uses_same_origin_asr_proxy():
|
| 64 |
+
source = Path("app.py").read_text(encoding="utf-8")
|
| 65 |
+
|
| 66 |
+
assert '@app.post("/dream-asr", include_in_schema=False)' in source
|
| 67 |
+
assert "_browser_asr_client()" in source
|
| 68 |
+
assert "fallback_enabled=False" in source
|
| 69 |
+
assert "asyncio.to_thread(asr_client.transcribe, temp_path)" in source
|
| 70 |
+
assert "BROWSER_ASR_TIMEOUT_SECONDS = 150.0" in source
|
| 71 |
+
assert "DREAM_CUSTOMS_HOSTED_TOKEN" not in source
|
| 72 |
|
| 73 |
|
| 74 |
def test_image_upload_is_composer_plus_drawer():
|