Route mic recording through MiMo ASR

#42
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=int(os.getenv("GRADIO_SERVER_PORT", "7860")),
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.2
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
- return [str(item).strip() for item in value if str(item).strip()]
 
 
 
 
 
 
 
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.2,
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.2,
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
- return self.fallback.transcribe(audio_path)
 
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
- return self.fallback.transcribe(audio_path)
907
- return _hosted_transcript_from_response(payload) or self.fallback.transcribe(audio_path)
 
 
 
 
 
 
 
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, ["海", "海浪", "水", "月牙", "moon", "sea", "wave", "water", "dark sea"]):
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
- f"Maybe this dream is not giving you a fixed answer. It is placing {_anchor_with_article(primary)} "
1175
- f"beside {_anchor_with_article(secondary)} so you can notice one small stuck point today."
 
1176
  )
1177
  return (
1178
- f"也许这个梦不是在给你一个确定答案,而是把「{primary}」和「{secondary}」一起,"
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"把「{primary}」当成现实生活的线索,不当成梦里给你的命令。",
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
- polished.safety_note = safety_note(language) if needs_escalation(merged) else ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 checkingTimer = null;
 
 
 
 
 
 
 
 
 
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 spacer = textarea.value.trim() ? "\n" : "";
113
- textarea.value = `${textarea.value}${spacer}${transcript}`;
114
- textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: transcript }));
115
- textarea.dispatchEvent(new Event("change", { bubbles: true }));
116
- textarea.focus();
 
 
 
 
 
 
 
 
 
 
 
117
  };
118
 
119
- button.addEventListener("click", async () => {
120
- const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition;
121
- if (!Recognition) {
122
- setStatus(messageFor("unsupported", "This browser cannot transcribe voice here. You can still type the dream."), "error");
123
- textarea.focus();
124
- return;
125
  }
 
 
 
 
 
 
 
 
 
 
 
126
 
127
- setStatus(messageFor("checking", "Checking microphone permission..."), "listening");
128
- let latestTranscript = "";
129
-
130
- const recognition = new Recognition();
131
- recognition.lang = button.dataset.language === "zh" ? "zh-CN" : "en-US";
132
- recognition.interimResults = true;
133
- recognition.continuous = false;
134
- recognition.maxAlternatives = 1;
135
- checkingTimer = window.setTimeout(() => {
136
- if (button.dataset.mode === "listening" && !latestTranscript) {
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
- recognition.onstart = () => {
148
- if (checkingTimer) {
149
- window.clearTimeout(checkingTimer);
150
- checkingTimer = null;
151
- }
152
- setStatus(messageFor("listening", "Listening. Say the dream fragment when you are ready."), "listening");
153
- };
154
 
155
- recognition.onresult = (event) => {
156
- latestTranscript = Array.from(event.results)
157
- .map((result) => result[0]?.transcript || "")
158
- .join("")
159
- .trim();
160
- if (latestTranscript) {
161
- setStatus(`Listening: ${latestTranscript}`, "listening");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  }
163
- };
164
-
165
- recognition.onerror = (event) => {
166
- if (checkingTimer) {
167
- window.clearTimeout(checkingTimer);
168
- checkingTimer = null;
 
169
  }
170
- const message = event.error === "not-allowed"
171
- ? messageFor("permission", "Microphone permission was denied. Allow recording and try again.")
172
- : messageFor("empty", "I did not catch that. Tap the microphone again if you want to retry.");
173
- setStatus(message, "error");
174
- };
 
 
 
 
 
175
 
176
- recognition.onend = () => {
177
- if (checkingTimer) {
178
- window.clearTimeout(checkingTimer);
179
- checkingTimer = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  }
181
- if (latestTranscript) {
182
- appendTranscript(latestTranscript);
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
- recognition.start();
 
 
 
 
 
 
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-checking="{escape('正在请求麦克风权限...' if language == 'zh' else 'Checking microphone permission...')}"
745
- data-timeout="{escape('语音识别等待太久了。你可以先继续手动输入梦境。' if language == 'zh' else 'Voice is taking too long here. You can keep typing the dream instead.')}"
 
 
 
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": "Tap the microphone to dictate",
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": "Listening. Say the dream fragment when you are ready.",
29
- "mic_done": "Added to the dream note.",
 
 
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 transcribed by the ASR adapter when you continue.",
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
- "mic_done": "已加入梦境记录。",
 
 
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="listening"] {
 
 
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="listening"] .dc-mic-glyph,
480
- .dc-mic-button[data-mode="listening"] .dc-mic-glyph::before {
 
 
 
 
481
  border-color: #fff9ee;
482
  }
483
 
484
- .dc-mic-button[data-mode="listening"] .dc-mic-glyph::after {
 
 
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="listening"],
 
 
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.2)
34
  try:
35
  temperature = float(temperature)
36
  except (TypeError, ValueError):
37
- temperature = 0.2
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 = "openai/whisper-tiny"
 
 
 
 
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 transformers import pipeline
125
 
126
- _ASR_PIPE = pipeline(
127
- "automatic-speech-recognition",
128
- model=os.getenv("DREAM_CUSTOMS_ASR_MODEL", ASR_MODEL),
129
- device_map="auto",
130
- torch_dtype="auto",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=image,
471
- gpu="L4",
472
- timeout=10 * MINUTES,
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
- pipe = _load_asr_pipe()
493
  with tempfile.NamedTemporaryFile(suffix=suffix) as temp_file:
494
  temp_file.write(audio_bytes)
495
  temp_file.flush()
496
- result = pipe(temp_file.name)
497
- transcript = _stringify_pipeline_result(result)
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", "127.0.0.1"))
57
- parser.add_argument("--port", type=int, default=int(os.getenv("GRADIO_SERVER_PORT", "7862")))
 
 
 
 
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 load_runtime_env_json, mirror_manifest
 
 
 
 
 
 
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", 7862)
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.2
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 test_voice_input_keeps_modal_asr_component_but_uses_inline_mic_button():
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-timeout="' in ui_app._mic_html("en")
51
- assert "Voice is taking too long here" in ui_app._mic_html("en")
52
- assert "window.setTimeout" in app_source
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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():