Improve Dream QA Modal feedback latency

#45
by ADJCJH - opened
app.py CHANGED
@@ -15,7 +15,7 @@ from dream_customs.ui.app import build_demo
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:
@@ -32,7 +32,7 @@ def _browser_asr_timeout_seconds() -> float:
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
 
 
15
 
16
  LOCAL_GRADIO_PORT = 7862
17
  HF_SPACE_GRADIO_PORT = 7860
18
+ BROWSER_ASR_TIMEOUT_SECONDS = 20.0
19
 
20
 
21
  def _default_server_port() -> int:
 
32
  if not value:
33
  return BROWSER_ASR_TIMEOUT_SECONDS
34
  try:
35
+ return max(5.0, float(value))
36
  except ValueError:
37
  return BROWSER_ASR_TIMEOUT_SECONDS
38
 
dream_customs/app_logic.py CHANGED
@@ -39,7 +39,7 @@ 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
44
  DEFAULT_TEXT_LATENCY_BUDGET_MS = 3500
45
  DEFAULT_VISION_LATENCY_BUDGET_MS = 6500
 
39
  DEFAULT_ASR_TIMEOUT_SECONDS = 45.0
40
  DEFAULT_TEXT_TEMPERATURE = 0.0
41
  DEFAULT_VISION_TEMPERATURE = 0.1
42
+ DEFAULT_TEXT_MAX_TOKENS = 560
43
  DEFAULT_VISION_MAX_TOKENS = 320
44
  DEFAULT_TEXT_LATENCY_BUDGET_MS = 3500
45
  DEFAULT_VISION_LATENCY_BUDGET_MS = 6500
dream_customs/models.py CHANGED
@@ -362,7 +362,7 @@ class OllamaTextClient:
362
  parsed = self._generate_json(
363
  prompt,
364
  '{"visitor_name":"string","questions":["string","string"],"tone_note":"string"}',
365
- num_predict=320,
366
  )
367
  if not parsed:
368
  return self.fallback.generate_negotiation(prompt)
@@ -383,7 +383,7 @@ class OllamaTextClient:
383
  '"risk_level":"string","alliance_reading":"string","practical_suggestion":"string",'
384
  '"weird_task":"string","bedtime_release":"string","safety_note":"string"}'
385
  ),
386
- num_predict=700,
387
  )
388
  if not parsed:
389
  return self.fallback.generate_pact(prompt)
@@ -410,7 +410,7 @@ class OllamaTextClient:
410
  '"followup_questions":["string"],"user_answers":["string"],"interpretation":"string",'
411
  '"today_tip":"string","tiny_action":"string","caring_note":"string","safety_note":"string"}'
412
  ),
413
- num_predict=780,
414
  )
415
  if not parsed:
416
  return self.fallback.generate_today_tip(prompt)
@@ -426,7 +426,7 @@ class OllamaTextClient:
426
  '"today_bridge":"string","visual_evidence":["string"],'
427
  '"safety_flags":["string"],"language":"en"}'
428
  ),
429
- num_predict=520,
430
  )
431
  if not parsed:
432
  return self.fallback.generate_brief(prompt)
@@ -627,6 +627,7 @@ class HostedMiniCPMTextClient:
627
  timeout: float = 60.0,
628
  temperature: float = 0.0,
629
  max_tokens: int = 780,
 
630
  fallback: Optional[FakeTextClient] = None,
631
  ):
632
  self.endpoint = endpoint.strip()
@@ -634,8 +635,15 @@ class HostedMiniCPMTextClient:
634
  self.timeout = timeout
635
  self.temperature = max(0.0, min(float(temperature), 0.7))
636
  self.max_tokens = max(64, min(int(max_tokens), 1200))
 
637
  self.fallback = fallback or FakeTextClient()
638
 
 
 
 
 
 
 
639
  def _post_json(self, prompt: str, max_tokens: int = 700) -> Optional[Dict[str, Any]]:
640
  if not self.endpoint:
641
  return None
@@ -656,7 +664,7 @@ class HostedMiniCPMTextClient:
656
  method="POST",
657
  )
658
  try:
659
- with urllib.request.urlopen(request, timeout=self.timeout) as response:
660
  return json.loads(response.read().decode("utf-8"))
661
  except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError):
662
  return None
@@ -676,7 +684,7 @@ class HostedMiniCPMTextClient:
676
  parsed = self._generate_json(
677
  prompt,
678
  '{"visitor_name":"string","questions":["string"],"tone_note":"string"}',
679
- max_tokens=360,
680
  )
681
  if not parsed:
682
  return self.fallback.generate_negotiation(prompt)
@@ -697,7 +705,7 @@ class HostedMiniCPMTextClient:
697
  '"risk_level":"string","alliance_reading":"string","practical_suggestion":"string",'
698
  '"weird_task":"string","bedtime_release":"string","safety_note":"string"}'
699
  ),
700
- max_tokens=780,
701
  )
702
  if not parsed:
703
  return self.fallback.generate_pact(prompt)
@@ -724,7 +732,7 @@ class HostedMiniCPMTextClient:
724
  '"followup_questions":["string"],"user_answers":["string"],"interpretation":"string",'
725
  '"today_tip":"string","tiny_action":"string","caring_note":"string","safety_note":"string"}'
726
  ),
727
- max_tokens=780,
728
  )
729
  if not parsed:
730
  return self.fallback.generate_today_tip(prompt)
@@ -740,7 +748,7 @@ class HostedMiniCPMTextClient:
740
  '"today_bridge":"string","visual_evidence":["string"],'
741
  '"safety_flags":["string"],"language":"en"}'
742
  ),
743
- max_tokens=520,
744
  )
745
  if not parsed:
746
  return self.fallback.generate_brief(prompt)
@@ -777,6 +785,7 @@ class HostedMiniCPMVisionClient:
777
  timeout: float = 60.0,
778
  temperature: float = 0.1,
779
  max_tokens: int = 320,
 
780
  fallback: Optional[FakeVisionClient] = None,
781
  ):
782
  self.endpoint = endpoint.strip()
@@ -784,8 +793,15 @@ class HostedMiniCPMVisionClient:
784
  self.timeout = timeout
785
  self.temperature = max(0.0, min(float(temperature), 0.7))
786
  self.max_tokens = max(64, min(int(max_tokens), 800))
 
787
  self.fallback = fallback or FakeVisionClient()
788
 
 
 
 
 
 
 
789
  def _post_image(self, image_path: str, prompt: Optional[str] = None) -> Optional[Dict[str, Any]]:
790
  if not self.endpoint:
791
  return None
@@ -811,7 +827,7 @@ class HostedMiniCPMVisionClient:
811
  method="POST",
812
  )
813
  try:
814
- with urllib.request.urlopen(request, timeout=self.timeout) as response:
815
  return json.loads(response.read().decode("utf-8"))
816
  except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError):
817
  return None
@@ -876,16 +892,24 @@ class HostedASRClient:
876
  endpoint: str = "",
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 ""
@@ -918,7 +942,7 @@ class HostedASRClient:
918
  method="POST",
919
  )
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}"
 
362
  parsed = self._generate_json(
363
  prompt,
364
  '{"visitor_name":"string","questions":["string","string"],"tone_note":"string"}',
365
+ num_predict=240,
366
  )
367
  if not parsed:
368
  return self.fallback.generate_negotiation(prompt)
 
383
  '"risk_level":"string","alliance_reading":"string","practical_suggestion":"string",'
384
  '"weird_task":"string","bedtime_release":"string","safety_note":"string"}'
385
  ),
386
+ num_predict=560,
387
  )
388
  if not parsed:
389
  return self.fallback.generate_pact(prompt)
 
410
  '"followup_questions":["string"],"user_answers":["string"],"interpretation":"string",'
411
  '"today_tip":"string","tiny_action":"string","caring_note":"string","safety_note":"string"}'
412
  ),
413
+ num_predict=560,
414
  )
415
  if not parsed:
416
  return self.fallback.generate_today_tip(prompt)
 
426
  '"today_bridge":"string","visual_evidence":["string"],'
427
  '"safety_flags":["string"],"language":"en"}'
428
  ),
429
+ num_predict=360,
430
  )
431
  if not parsed:
432
  return self.fallback.generate_brief(prompt)
 
627
  timeout: float = 60.0,
628
  temperature: float = 0.0,
629
  max_tokens: int = 780,
630
+ latency_budget_ms: int = 0,
631
  fallback: Optional[FakeTextClient] = None,
632
  ):
633
  self.endpoint = endpoint.strip()
 
635
  self.timeout = timeout
636
  self.temperature = max(0.0, min(float(temperature), 0.7))
637
  self.max_tokens = max(64, min(int(max_tokens), 1200))
638
+ self.latency_budget_ms = max(0, int(latency_budget_ms or 0))
639
  self.fallback = fallback or FakeTextClient()
640
 
641
+ @property
642
+ def request_timeout(self) -> float:
643
+ if self.latency_budget_ms <= 0:
644
+ return self.timeout
645
+ return max(1.0, min(float(self.timeout), self.latency_budget_ms / 1000.0))
646
+
647
  def _post_json(self, prompt: str, max_tokens: int = 700) -> Optional[Dict[str, Any]]:
648
  if not self.endpoint:
649
  return None
 
664
  method="POST",
665
  )
666
  try:
667
+ with urllib.request.urlopen(request, timeout=self.request_timeout) as response:
668
  return json.loads(response.read().decode("utf-8"))
669
  except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError):
670
  return None
 
684
  parsed = self._generate_json(
685
  prompt,
686
  '{"visitor_name":"string","questions":["string"],"tone_note":"string"}',
687
+ max_tokens=240,
688
  )
689
  if not parsed:
690
  return self.fallback.generate_negotiation(prompt)
 
705
  '"risk_level":"string","alliance_reading":"string","practical_suggestion":"string",'
706
  '"weird_task":"string","bedtime_release":"string","safety_note":"string"}'
707
  ),
708
+ max_tokens=560,
709
  )
710
  if not parsed:
711
  return self.fallback.generate_pact(prompt)
 
732
  '"followup_questions":["string"],"user_answers":["string"],"interpretation":"string",'
733
  '"today_tip":"string","tiny_action":"string","caring_note":"string","safety_note":"string"}'
734
  ),
735
+ max_tokens=560,
736
  )
737
  if not parsed:
738
  return self.fallback.generate_today_tip(prompt)
 
748
  '"today_bridge":"string","visual_evidence":["string"],'
749
  '"safety_flags":["string"],"language":"en"}'
750
  ),
751
+ max_tokens=360,
752
  )
753
  if not parsed:
754
  return self.fallback.generate_brief(prompt)
 
785
  timeout: float = 60.0,
786
  temperature: float = 0.1,
787
  max_tokens: int = 320,
788
+ latency_budget_ms: int = 0,
789
  fallback: Optional[FakeVisionClient] = None,
790
  ):
791
  self.endpoint = endpoint.strip()
 
793
  self.timeout = timeout
794
  self.temperature = max(0.0, min(float(temperature), 0.7))
795
  self.max_tokens = max(64, min(int(max_tokens), 800))
796
+ self.latency_budget_ms = max(0, int(latency_budget_ms or 0))
797
  self.fallback = fallback or FakeVisionClient()
798
 
799
+ @property
800
+ def request_timeout(self) -> float:
801
+ if self.latency_budget_ms <= 0:
802
+ return self.timeout
803
+ return max(1.0, min(float(self.timeout), self.latency_budget_ms / 1000.0))
804
+
805
  def _post_image(self, image_path: str, prompt: Optional[str] = None) -> Optional[Dict[str, Any]]:
806
  if not self.endpoint:
807
  return None
 
827
  method="POST",
828
  )
829
  try:
830
+ with urllib.request.urlopen(request, timeout=self.request_timeout) as response:
831
  return json.loads(response.read().decode("utf-8"))
832
  except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError):
833
  return None
 
892
  endpoint: str = "",
893
  token: str = "",
894
  timeout: float = 45.0,
895
+ latency_budget_ms: int = 0,
896
  fallback: Optional[FakeASRClient] = None,
897
  fallback_enabled: bool = True,
898
  ):
899
  self.endpoint = endpoint.strip()
900
  self.token = token.strip()
901
  self.timeout = timeout
902
+ self.latency_budget_ms = max(0, int(latency_budget_ms or 0))
903
  self.fallback = fallback or FakeASRClient()
904
  self.fallback_enabled = fallback_enabled
905
  self.last_error = ""
906
 
907
+ @property
908
+ def request_timeout(self) -> float:
909
+ if self.latency_budget_ms <= 0:
910
+ return self.timeout
911
+ return max(1.0, min(float(self.timeout), self.latency_budget_ms / 1000.0))
912
+
913
  def _fallback_transcript(self, audio_path: Optional[str]) -> str:
914
  if not self.fallback_enabled:
915
  return ""
 
942
  method="POST",
943
  )
944
  try:
945
+ with urllib.request.urlopen(request, timeout=self.request_timeout) as response:
946
  payload = json.loads(response.read().decode("utf-8"))
947
  except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError) as exc:
948
  self.last_error = f"{exc.__class__.__name__}: {exc}"
dream_customs/prompts.py CHANGED
@@ -58,7 +58,7 @@ Write a non-diagnostic interpretation draft, one waking-life Today Tip / 今日
58
  and one weird little thing / 古怪的小事.
59
  First answer the user's stated question directly. If the user sounds scared, sad,
60
  overwhelmed, guilty, lonely, or asks for comfort, follow that emotion before giving any action.
61
- The interpretation must be step-by-step: use 2 to 4 short layers that move from
62
  the user's feeling, to concrete dream anchors, to the follow-up answers, to one gentle way to care for today.
63
  Do not collapse every dream into productivity advice such as opening a task,
64
  writing a first line, or making the first step smaller.
@@ -79,7 +79,8 @@ or a reason to seek professional help unless the user explicitly reports severe
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".
85
  If the user asks for comfort, caring_note should be warm, specific, and validating.
 
58
  and one weird little thing / 古怪的小事.
59
  First answer the user's stated question directly. If the user sounds scared, sad,
60
  overwhelmed, guilty, lonely, or asks for comfort, follow that emotion before giving any action.
61
+ The interpretation must be compact: use 1 to 2 short layers that move from
62
  the user's feeling, to concrete dream anchors, to the follow-up answers, to one gentle way to care for today.
63
  Do not collapse every dream into productivity advice such as opening a task,
64
  writing a first line, or making the first step smaller.
 
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 JSON compact: each user-facing field should be one short sentence when possible.
83
+ Keep the whole result warm, emotionally responsive, and specific to the user's answer.
84
  The weird little thing must be harmless, legal, low-cost, non-embarrassing, and not a command to solve the whole problem.
85
  Avoid demanding phrases such as "immediately", "must", "fix it", or "solve it".
86
  If the user asks for comfort, caring_note should be warm, specific, and validating.
dream_customs/ui/app.py CHANGED
@@ -800,8 +800,8 @@ def _agent_dream_qa(dream_text: str, mood: str = "", answer: str = "", language:
800
  image_value=None,
801
  audio_value=None,
802
  mood=mood,
803
- text_backend=DEFAULT_TEXT_BACKEND,
804
- vision_backend=DEFAULT_VISION_BACKEND,
805
  language=language,
806
  )
807
  view = json.loads(view_json)
@@ -919,7 +919,7 @@ def _mic_html(language: str = DEFAULT_LANGUAGE) -> str:
919
  data-empty="{escape(copy['mic_empty'])}"
920
  data-error="{escape(copy['mic_error'])}"
921
  data-timeout="{escape(copy['mic_timeout'])}"
922
- data-timeout-ms="140000"
923
  >
924
  <span class="dc-mic-glyph" aria-hidden="true"></span>
925
  </button>
 
800
  image_value=None,
801
  audio_value=None,
802
  mood=mood,
803
+ text_backend="demo",
804
+ vision_backend="demo",
805
  language=language,
806
  )
807
  view = json.loads(view_json)
 
919
  data-empty="{escape(copy['mic_empty'])}"
920
  data-error="{escape(copy['mic_error'])}"
921
  data-timeout="{escape(copy['mic_timeout'])}"
922
+ data-timeout-ms="20000"
923
  >
924
  <span class="dc-mic-glyph" aria-hidden="true"></span>
925
  </button>
modal_backend/contracts.py CHANGED
@@ -24,11 +24,11 @@ def normalize_text_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
24
  if content:
25
  parts.append(content)
26
  prompt = "\n".join(parts).strip()
27
- max_tokens = payload.get("max_tokens", 700)
28
  try:
29
  max_tokens = int(max_tokens)
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:
 
24
  if content:
25
  parts.append(content)
26
  prompt = "\n".join(parts).strip()
27
+ max_tokens = payload.get("max_tokens", 560)
28
  try:
29
  max_tokens = int(max_tokens)
30
  except (TypeError, ValueError):
31
+ max_tokens = 560
32
  max_tokens = max(64, min(max_tokens, 1200))
33
  temperature = payload.get("temperature", 0.0)
34
  try:
tests/test_modal_contract.py CHANGED
@@ -22,7 +22,7 @@ def test_normalize_text_payload_accepts_openai_style_messages():
22
  {"messages": [{"role": "user", "content": "Dream of an elevator."}]}
23
  )
24
  assert payload["prompt"] == "Dream of an elevator."
25
- assert payload["max_tokens"] == 700
26
 
27
 
28
  def test_normalize_text_payload_clamps_max_tokens():
 
22
  {"messages": [{"role": "user", "content": "Dream of an elevator."}]}
23
  )
24
  assert payload["prompt"] == "Dream of an elevator."
25
+ assert payload["max_tokens"] == 560
26
 
27
 
28
  def test_normalize_text_payload_clamps_max_tokens():
tests/test_ui_actions.py CHANGED
@@ -71,10 +71,20 @@ def test_browser_voice_uses_same_origin_asr_proxy():
71
  assert "_browser_asr_client()" in source
72
  assert "fallback_enabled=False" in source
73
  assert "asyncio.to_thread(asr_client.transcribe, temp_path)" in source
74
- assert "BROWSER_ASR_TIMEOUT_SECONDS = 150.0" in source
 
75
  assert "DREAM_CUSTOMS_HOSTED_TOKEN" not in source
76
 
77
 
 
 
 
 
 
 
 
 
 
78
  def test_image_upload_is_composer_plus_drawer():
79
  source = inspect.getsource(ui_app.build_demo)
80
 
@@ -322,7 +332,7 @@ def test_mobile_reset_restores_calm_mood():
322
  60,
323
  0.2,
324
  0.1,
325
- 780,
326
  320,
327
  "demo",
328
  "",
 
71
  assert "_browser_asr_client()" in source
72
  assert "fallback_enabled=False" in source
73
  assert "asyncio.to_thread(asr_client.transcribe, temp_path)" in source
74
+ assert "BROWSER_ASR_TIMEOUT_SECONDS = 20.0" in source
75
+ assert 'data-timeout-ms="20000"' in ui_app._mic_html("en")
76
  assert "DREAM_CUSTOMS_HOSTED_TOKEN" not in source
77
 
78
 
79
+ def test_agent_api_uses_fast_local_question_before_modal_tip():
80
+ source = inspect.getsource(ui_app._agent_dream_qa)
81
+
82
+ assert 'text_backend="demo"' in source
83
+ assert 'vision_backend="demo"' in source
84
+ assert "answer_to_card_action(" in source
85
+ assert "skip_to_card_action(" in source
86
+
87
+
88
  def test_image_upload_is_composer_plus_drawer():
89
  source = inspect.getsource(ui_app.build_demo)
90
 
 
332
  60,
333
  0.2,
334
  0.1,
335
+ 560,
336
  320,
337
  "demo",
338
  "",