Finalize Dream QA agent hardening

#30
app.py CHANGED
@@ -1,5 +1,9 @@
1
  import os
2
 
 
 
 
 
3
  from dream_customs import zerogpu # noqa: F401
4
  from dream_customs.ui.app import build_demo
5
 
@@ -7,6 +11,50 @@ from dream_customs.ui.app import build_demo
7
  demo = build_demo()
8
 
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  if __name__ == "__main__":
11
  demo.launch(
12
  server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
 
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.ui.app import build_demo
9
 
 
11
  demo = build_demo()
12
 
13
 
14
+ _ORIGINAL_CREATE_APP = App.create_app
15
+
16
+
17
+ def _install_gradio_api_aliases(app, blocks) -> None:
18
+ existing_paths = {getattr(route, "path", "") for route in app.routes}
19
+
20
+ if "/gradio_api/info" not in existing_paths:
21
+ @app.get("/gradio_api/info", include_in_schema=False)
22
+ async def gradio_api_info_alias() -> JSONResponse:
23
+ """Compatibility for Hugging Face's generated agents.md schema URL."""
24
+
25
+ return JSONResponse(blocks.get_api_info())
26
+
27
+ async def _redirect_to_gradio4_path(request: Request) -> RedirectResponse:
28
+ suffix = request.url.path.removeprefix("/gradio_api")
29
+ target = suffix or "/"
30
+ if request.url.query:
31
+ target = f"{target}?{request.url.query}"
32
+ return RedirectResponse(url=target, status_code=307)
33
+
34
+ for alias_path in (
35
+ "/gradio_api/queue/join",
36
+ "/gradio_api/queue/data",
37
+ "/gradio_api/upload",
38
+ ):
39
+ if alias_path not in existing_paths:
40
+ app.add_api_route(
41
+ alias_path,
42
+ _redirect_to_gradio4_path,
43
+ methods=["GET", "POST"],
44
+ include_in_schema=False,
45
+ )
46
+
47
+
48
+ def _create_app_with_gradio_api_aliases(blocks, app_kwargs=None, auth_dependency=None):
49
+ app = _ORIGINAL_CREATE_APP(blocks, app_kwargs=app_kwargs, auth_dependency=auth_dependency)
50
+ _install_gradio_api_aliases(app, blocks)
51
+ return app
52
+
53
+
54
+ App.create_app = staticmethod(_create_app_with_gradio_api_aliases)
55
+ _install_gradio_api_aliases(demo.app, demo)
56
+
57
+
58
  if __name__ == "__main__":
59
  demo.launch(
60
  server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
dream_customs/pipeline.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import re
2
  from datetime import date
3
  from typing import Dict, List, Optional, Tuple
@@ -100,6 +101,90 @@ _ANCHOR_STOPWORDS = {
100
  }
101
 
102
  _ZH_ANCHOR_MARKERS = [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  "电梯按钮",
104
  "融化的按钮",
105
  "按钮融化",
@@ -118,6 +203,92 @@ _ZH_ANCHOR_MARKERS = [
118
  ]
119
 
120
  _ZH_TO_EN_PHRASES = {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  "电梯按钮": "elevator button",
122
  "融化的按钮": "melted button",
123
  "按钮融化": "melted button",
@@ -145,6 +316,92 @@ def _dedupe_preserve_order(items: List[str]) -> List[str]:
145
  return result
146
 
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  def _extract_dream_anchors(intake: DreamIntake) -> List[str]:
149
  raw_text = " ".join(
150
  [
@@ -156,6 +413,8 @@ def _extract_dream_anchors(intake: DreamIntake) -> List[str]:
156
  )
157
  text = raw_text.lower()
158
  candidates: List[str] = []
 
 
159
  for marker in _ZH_ANCHOR_MARKERS:
160
  if marker in raw_text:
161
  candidates.append(marker)
@@ -167,22 +426,29 @@ def _extract_dream_anchors(intake: DreamIntake) -> List[str]:
167
  r"paper|papers|promise|promises|window|windows|suitcase|suitcases|"
168
  r"clerk|clerks|sunrise|elevator|elevators|button|buttons|hallway|"
169
  r"gate|gates|floor|floors|stamp|stamps|number|numbers|exam|exams|"
170
- r"pencil|pencils|train|trains|door|doors|phone|phones|water|shoe|shoes|"
171
- r"stairwell|stairwells|room|rooms|key|keys|note|notes|rain|moon|moons|curtain|curtains"
 
 
 
 
172
  r")\b"
173
  )
174
  for match in pair_pattern.finditer(text):
175
  modifier, noun = match.groups()
176
- phrase = f"{modifier} {noun.rstrip('s')}"
 
177
  if modifier not in _ANCHOR_STOPWORDS:
178
  candidates.append(phrase)
179
 
180
  noun_pattern = re.compile(
181
  r"\b(customs|suitcase|clerk|sunrise|elevator|button|hallway|gate|stamp|number|floor|"
182
- r"exam|pencil|train|door|phone|water|shoe|stairwell|room|key|note|rain|moon|curtain|sleep|dream)\b"
 
 
183
  )
184
  candidates.extend(match.group(1) for match in noun_pattern.finditer(text))
185
- candidates.extend(clue.lower() for clue in intake.visual_clues if clue.strip())
186
 
187
  return _dedupe_preserve_order(candidates)[:5]
188
 
@@ -191,7 +457,7 @@ def _english_anchor_text(text: str) -> str:
191
  clean = text or ""
192
  for source, target in sorted(_ZH_TO_EN_PHRASES.items(), key=lambda item: len(item[0]), reverse=True):
193
  clean = clean.replace(source, target)
194
- clean = re.sub(r"[\u4e00-\u9fff]+", "dream detail", clean)
195
  clean = re.sub(r"\s+", " ", clean).strip(" .,:;!?\"'()[]{}")
196
  return clean
197
 
@@ -224,6 +490,18 @@ def _clean_english_today_tip_language(card: TodayTipCard) -> TodayTipCard:
224
  return cleaned
225
 
226
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  def _primary_anchor(intake: DreamIntake, language: str = "en") -> str:
228
  anchors = _anchors_for_language(intake, language)
229
  if anchors:
@@ -236,6 +514,13 @@ def _secondary_anchor(intake: DreamIntake, language: str = "en") -> str:
236
  return anchors[1] if len(anchors) > 1 else _primary_anchor(intake, language)
237
 
238
 
 
 
 
 
 
 
 
239
  def _title_anchor(text: str) -> str:
240
  return " ".join(part.capitalize() for part in text.split())
241
 
@@ -249,17 +534,30 @@ def _summary_from_intake(intake: DreamIntake, language: str = "en") -> str:
249
  clean = re.sub(r"\s+", " ", merged).strip()
250
  if len(clean) > 72:
251
  clean = clean[:69].rstrip() + "..."
 
 
252
  if not _is_zh(language):
253
  return clean if clean.lower().startswith(("i ", "you ")) else f"You dreamed about {clean}"
254
- return f"你梦见{clean}" if not clean.startswith(("你", "我", "I ", "i ")) else clean
 
 
255
 
256
 
257
  def _main_question_from_intake(intake: DreamIntake, language: str = "en") -> str:
258
  if intake.main_question.strip():
259
  return intake.main_question.strip()
 
 
 
 
 
 
 
 
 
260
  primary = _primary_anchor(intake, language)
261
  if not _is_zh(language):
262
- return f"What might the {primary} be asking me to notice today?"
263
  return f"这个梦里的「{primary}」可能在提醒我什么?"
264
 
265
 
@@ -268,8 +566,8 @@ def _fallback_interpretation(intake: DreamIntake, language: str = "en") -> str:
268
  secondary = _secondary_anchor(intake, language)
269
  if not _is_zh(language):
270
  return (
271
- f"Maybe this dream is not giving you a fixed answer. It is placing the {primary} "
272
- f"beside the {secondary} so you can notice one small stuck point today."
273
  )
274
  return (
275
  f"也许这个梦不是在给你一个确定答案,而是把「{primary}」和「{secondary}」放到一起,"
@@ -281,7 +579,7 @@ def _grounded_today_tip(intake: DreamIntake, language: str = "en") -> str:
281
  primary = _primary_anchor(intake, language)
282
  if not _is_zh(language):
283
  return (
284
- f"For today, borrow one action from the {primary}: open the task, write only the first line, "
285
  "and let that be enough for now."
286
  )
287
  return f"今天先从「{primary}」借一个动作:只做最小的第一步,不急着把整件事完成。"
@@ -292,9 +590,21 @@ def _answer_based_tiny_action(answers: str, language: str = "en") -> str:
292
  if _is_zh(language):
293
  if "邮件" in lowered or "email" in lowered:
294
  return "给自己 5 分钟,只打开那封邮件,写��第一句话;今天不要求立刻发出。"
 
 
 
 
295
  return ""
296
  if "email" in lowered or "message" in lowered:
297
  return "Set a five-minute timer, open the email, and write only the first sentence. You do not have to send it yet."
 
 
 
 
 
 
 
 
298
  return ""
299
 
300
 
@@ -303,12 +613,27 @@ def _answer_based_today_tip(answers: str, anchor: str, language: str = "en") ->
303
  if _is_zh(language):
304
  if "邮件" in lowered or "email" in lowered:
305
  return f"今天把「{anchor}」当成允许慢慢开始的按钮:只打开那封邮件,先写第一句话。"
 
 
 
 
306
  return ""
307
  if "email" in lowered or "message" in lowered:
308
  return (
309
  f"For today, treat the {anchor} as permission to start gently: "
310
  "open the overdue email and write only the first sentence."
311
  )
 
 
 
 
 
 
 
 
 
 
 
312
  return ""
313
 
314
 
@@ -319,6 +644,8 @@ def _answer_based_interpretation(answers: str, anchor: str, language: str = "en"
319
  return f"也许「{anchor}」不是在催你立刻完成什么,而是在提醒你:那封邮件可以先从一句话开始。"
320
  if "消息" in lowered or "message" in lowered:
321
  return f"也许「{anchor}」不是在催你立刻回应什么,而是在提醒你:那条消息可以先从一句草稿开始。"
 
 
322
  return ""
323
  if "email" in lowered:
324
  return (
@@ -330,6 +657,26 @@ def _answer_based_interpretation(answers: str, anchor: str, language: str = "en"
330
  f"Maybe the {anchor} is not asking you to answer every message at once. "
331
  "It is pointing to the gentler threshold: opening one thread and drafting one first sentence."
332
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  return ""
334
 
335
 
@@ -350,7 +697,16 @@ def _anchor_in_text(text: str, anchors: List[str]) -> bool:
350
 
351
  def _text_uses_anchor(text: str, anchors: List[str]) -> bool:
352
  clean = (text or "").lower()
353
- return any(anchor in clean for anchor in anchors)
 
 
 
 
 
 
 
 
 
354
 
355
 
356
  def _is_generic_visitor_name(text: str, intake: DreamIntake) -> bool:
@@ -499,6 +855,107 @@ def _grounded_question(intake: DreamIntake, question: str, language: str = "en")
499
  )
500
 
501
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  def _grounded_followup_question(intake: DreamIntake, language: str = "en") -> str:
503
  primary = _primary_anchor(intake, language)
504
  if not _is_zh(language):
@@ -589,7 +1046,7 @@ def build_qa_state(
589
  answers: Optional[List[str]] = None,
590
  language: str = "en",
591
  ) -> DreamQAState:
592
- anchors = _anchors_for_language(intake, language)
593
  return DreamQAState(
594
  dream_summary=_summary_from_intake(intake, language),
595
  main_question=_main_question_from_intake(intake, language),
@@ -602,8 +1059,11 @@ def build_qa_state(
602
 
603
  def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = "", language: str = "en") -> TodayTipCard:
604
  polished = card.model_copy(deep=True)
605
- intake_anchors = _anchors_for_language(intake, language)
606
- card_anchors = (
 
 
 
607
  polished.dream_anchors
608
  if _is_zh(language)
609
  else _dedupe_preserve_order([_english_anchor_text(anchor) for anchor in polished.dream_anchors])
@@ -613,39 +1073,94 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
613
  else:
614
  anchors = card_anchors or intake_anchors
615
  if not anchors:
616
- anchors = [_primary_anchor(intake, language)]
 
 
617
  polished.dream_anchors = anchors
618
- if not polished.dream_summary.strip():
 
 
 
 
 
 
 
 
 
 
619
  polished.dream_summary = _summary_from_intake(intake, language)
620
- if not polished.main_question.strip():
 
 
 
 
621
  polished.main_question = _main_question_from_intake(intake, language)
622
  answer_interpretation = _answer_based_interpretation(answers, _answer_bridge_anchor(anchors), language)
623
  if answer_interpretation:
624
  polished.interpretation = answer_interpretation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
625
  elif not polished.interpretation.strip() or not _anchor_in_text(polished.interpretation, anchors):
626
  polished.interpretation = _fallback_interpretation(intake, language)
627
  generic_tip_markers = ["drink water", "hydrate", "多休息", "保持积极", "take a walk"]
628
  answer_tip = _answer_based_today_tip(answers, anchors[0], language)
629
- if (
630
- answer_tip
631
- and ("email" in (answers or "").lower() or "邮件" in (answers or "").lower())
632
- ):
633
  polished.today_tip = answer_tip
 
 
 
 
 
 
 
 
 
 
 
 
 
 
634
  elif (
635
  not polished.today_tip.strip()
636
  or any(marker in polished.today_tip.lower() for marker in generic_tip_markers)
 
637
  or not _anchor_in_text(polished.today_tip, anchors)
638
  ):
639
  polished.today_tip = _grounded_today_tip(intake, language)
640
  hard_action_markers = ["address it immediately", "fix it immediately", "solve it immediately"]
641
  answer_action = _answer_based_tiny_action(answers, language)
642
- if answer_action and (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
643
  not polished.tiny_action.strip()
 
 
644
  or any(marker in polished.tiny_action.lower() for marker in hard_action_markers)
645
- or "email" in (answers or "").lower()
646
  ):
647
- polished.tiny_action = answer_action
648
- elif not polished.tiny_action.strip() or any(marker in polished.tiny_action.lower() for marker in hard_action_markers):
649
  if _is_zh(language):
650
  polished.tiny_action = f"用 5 分钟写下:今天和「{anchors[0]}」有关的第一小步是什么?"
651
  else:
@@ -656,16 +1171,40 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
656
  if _is_zh(language)
657
  else "You do not have to solve the whole dream this morning; noticing one detail is enough."
658
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
659
  merged = "\n".join([intake.merged_text(), answers or ""])
660
- polished.safety_note = safety_note() if needs_escalation(merged) else ""
661
  if not _is_zh(language):
662
  polished = _clean_english_today_tip_language(polished)
663
  return polished
664
 
665
 
666
- def generate_today_tip(intake: DreamIntake, answers: str, text_client, language: str = "en") -> TodayTipCard:
 
 
 
 
 
 
667
  language = _normalize_language(language)
668
- qa_state = build_qa_state(intake, answers=[answer for answer in [answers] if answer], language=language)
 
 
 
 
 
669
  prompt = today_tip_prompt(qa_state, language=language)
670
  try:
671
  if hasattr(text_client, "generate_today_tip"):
@@ -694,6 +1233,9 @@ def generate_today_tip(intake: DreamIntake, answers: str, text_client, language:
694
  interpretation=_fallback_interpretation(intake, language),
695
  today_tip=_grounded_today_tip(intake, language),
696
  )
 
 
 
697
  return _polish_today_tip(card, intake, answers, language)
698
 
699
 
@@ -760,6 +1302,7 @@ def _supports_model_led_pact(text_client) -> bool:
760
  def create_session(language: str = "en") -> CustomsSession:
761
  language = _normalize_language(language)
762
  return CustomsSession(
 
763
  events=[
764
  TimelineEvent(
765
  role="system",
@@ -827,6 +1370,7 @@ def add_evidence(
827
  ) -> CustomsSession:
828
  language = _normalize_language(language)
829
  next_session = session.model_copy(deep=True)
 
830
  added_items: List[EvidenceItem] = []
831
 
832
  if dream_text and dream_text.strip():
@@ -950,6 +1494,7 @@ def add_evidence(
950
  def ask_questions(session: CustomsSession, text_client, force_another: bool = False, language: str = "en") -> CustomsSession:
951
  language = _normalize_language(language)
952
  next_session = session.model_copy(deep=True)
 
953
  if not next_session.intake.merged_text():
954
  next_session.phase = "error"
955
  next_session.events.append(
@@ -971,6 +1516,9 @@ def ask_questions(session: CustomsSession, text_client, force_another: bool = Fa
971
  )
972
  negotiation = text_client.generate_negotiation(prompt)
973
  questions = [question for question in negotiation.get("questions", []) if question]
 
 
 
974
  fresh_questions = [question for question in questions if question not in next_session.question_history]
975
  if force_another and not fresh_questions:
976
  fresh_questions = [
@@ -1018,6 +1566,7 @@ def ask_questions(session: CustomsSession, text_client, force_another: bool = Fa
1018
  def answer_question(session: CustomsSession, answer: str, language: str = "en") -> CustomsSession:
1019
  language = _normalize_language(language)
1020
  next_session = session.model_copy(deep=True)
 
1021
  if not answer or not answer.strip():
1022
  next_session.phase = "error"
1023
  next_session.events.append(
@@ -1037,6 +1586,7 @@ def answer_question(session: CustomsSession, answer: str, language: str = "en")
1037
  def skip_question(session: CustomsSession, language: str = "en") -> CustomsSession:
1038
  language = _normalize_language(language)
1039
  next_session = session.model_copy(deep=True)
 
1040
  skip_text = "用户选择跳过这个追问。" if _is_zh(language) else "The user chose to skip this question."
1041
  next_session.answer_history.append(skip_text)
1042
  next_session.phase = "ask"
@@ -1050,6 +1600,7 @@ def skip_question(session: CustomsSession, language: str = "en") -> CustomsSessi
1050
  def finish_today_tip(session: CustomsSession, text_client, language: str = "en") -> CustomsSession:
1051
  language = _normalize_language(language)
1052
  next_session = session.model_copy(deep=True)
 
1053
  if not next_session.intake.merged_text():
1054
  next_session.phase = "error"
1055
  next_session.events.append(
@@ -1064,7 +1615,13 @@ def finish_today_tip(session: CustomsSession, text_client, language: str = "en")
1064
  )
1065
  return next_session
1066
  answers = next_session.answers_text()
1067
- card = generate_today_tip(next_session.intake, answers, text_client, language=language)
 
 
 
 
 
 
1068
  next_session.qa_state = build_qa_state(
1069
  next_session.intake, next_session.question_history, next_session.answer_history, language=language
1070
  )
 
1
+ import json
2
  import re
3
  from datetime import date
4
  from typing import Dict, List, Optional, Tuple
 
101
  }
102
 
103
  _ZH_ANCHOR_MARKERS = [
104
+ "高楼边缘",
105
+ "高楼",
106
+ "边缘",
107
+ "掉进海里",
108
+ "掉进海",
109
+ "海里",
110
+ "海",
111
+ "小孩找不到家",
112
+ "小孩",
113
+ "找不到家",
114
+ "潮湿的森林",
115
+ "潮湿森林",
116
+ "戴面具的人",
117
+ "面具",
118
+ "追逐",
119
+ "被追逐",
120
+ "犯的错误",
121
+ "过去犯的错误",
122
+ "错误",
123
+ "自责",
124
+ "重演",
125
+ "心率",
126
+ "噩梦",
127
+ "失眠",
128
+ "红色的门",
129
+ "红色门",
130
+ "海边",
131
+ "海浪",
132
+ "森林",
133
+ "白色猫",
134
+ "黑猫",
135
+ "猫",
136
+ "空白路牌",
137
+ "路牌",
138
+ "大雾",
139
+ "雾很大",
140
+ "雾",
141
+ "红色胡同",
142
+ "胡同",
143
+ "便签",
144
+ "草图",
145
+ "蓝色的小屋",
146
+ "小屋",
147
+ "红色邮箱",
148
+ "邮箱",
149
+ "红蓝两条路",
150
+ "两条路",
151
+ "旧房子",
152
+ "天花板",
153
+ "掉灰",
154
+ "考试",
155
+ "掉队",
156
+ "旧钥匙",
157
+ "钥匙",
158
+ "没名字的人",
159
+ "陌生的人",
160
+ "白色走廊",
161
+ "旧图书馆",
162
+ "图书馆",
163
+ "红色楼梯",
164
+ "楼梯",
165
+ "大雨",
166
+ "窗",
167
+ "家门",
168
+ "旧家",
169
+ "楼顶",
170
+ "漆黑的走廊",
171
+ "漆黑走廊",
172
+ "黑暗走廊",
173
+ "走廊",
174
+ "教室",
175
+ "老师",
176
+ "交作业",
177
+ "作业",
178
+ "门牌子",
179
+ "门牌",
180
+ "脚步声",
181
+ "心跳",
182
+ "大雪地",
183
+ "雪地",
184
+ "地铁站",
185
+ "地铁",
186
+ "安全帽",
187
+ "男生",
188
  "电梯按钮",
189
  "融化的按钮",
190
  "按钮融化",
 
203
  ]
204
 
205
  _ZH_TO_EN_PHRASES = {
206
+ "高楼边缘": "high-rise edge",
207
+ "高楼": "high-rise",
208
+ "边缘": "edge",
209
+ "掉进海里": "falling into the sea",
210
+ "掉进海": "falling into the sea",
211
+ "海里": "sea",
212
+ "海": "sea",
213
+ "小孩找不到家": "child unable to find home",
214
+ "小孩": "child",
215
+ "找不到家": "unable to find home",
216
+ "潮湿的森林": "wet forest",
217
+ "潮湿森林": "wet forest",
218
+ "戴面具的人": "masked person",
219
+ "面具": "mask",
220
+ "追逐": "chase",
221
+ "被追逐": "chase",
222
+ "犯的错误": "past mistake",
223
+ "过去犯的错误": "past mistake",
224
+ "错误": "mistake",
225
+ "自责": "self-blame",
226
+ "重演": "replay",
227
+ "心率": "fast heartbeat",
228
+ "噩梦": "nightmare",
229
+ "失眠": "insomnia",
230
+ "红色的门": "red door",
231
+ "红色门": "red door",
232
+ "海边": "seaside",
233
+ "海浪": "wave",
234
+ "大雾": "heavy fog",
235
+ "雾很大": "heavy fog",
236
+ "雾": "fog",
237
+ "森林": "forest",
238
+ "白色猫": "white cat",
239
+ "黑猫": "black cat",
240
+ "猫": "cat",
241
+ "路牌": "road sign",
242
+ "空白路牌": "blank road sign",
243
+ "红色胡同": "red alley",
244
+ "胡同": "alley",
245
+ "便签": "note",
246
+ "草图": "sketch",
247
+ "蓝色的小屋": "blue small house",
248
+ "小屋": "small house",
249
+ "红色邮箱": "red mailbox",
250
+ "邮箱": "mailbox",
251
+ "红蓝两条路": "red and blue roads",
252
+ "两条路": "two roads",
253
+ "旧房子": "old house",
254
+ "天花板": "ceiling",
255
+ "掉灰": "falling dust",
256
+ "考试": "exam",
257
+ "掉队": "falling behind",
258
+ "旧钥匙": "old key",
259
+ "钥匙": "key",
260
+ "没名字的人": "nameless person",
261
+ "陌生的人": "stranger",
262
+ "白色走廊": "white hallway",
263
+ "旧图书馆": "old library",
264
+ "图书馆": "library",
265
+ "红色楼梯": "red staircase",
266
+ "楼梯": "staircase",
267
+ "大雨": "heavy rain",
268
+ "雨": "rain",
269
+ "开着的窗": "open window",
270
+ "窗": "window",
271
+ "家门": "home door",
272
+ "旧家": "old home",
273
+ "楼顶": "rooftop",
274
+ "漆黑的走廊": "dark hallway",
275
+ "漆黑走廊": "dark hallway",
276
+ "黑暗走廊": "dark hallway",
277
+ "走廊": "hallway",
278
+ "教室": "classroom",
279
+ "老师": "teacher",
280
+ "交作业": "assignment",
281
+ "作业": "assignment",
282
+ "门牌子": "door sign",
283
+ "门牌": "door sign",
284
+ "脚步声": "footsteps",
285
+ "心跳": "heartbeat",
286
+ "大雪地": "snowfield",
287
+ "雪地": "snow",
288
+ "地铁站": "subway station",
289
+ "地铁": "subway",
290
+ "安全帽": "hard hat",
291
+ "男生": "young man",
292
  "电梯按钮": "elevator button",
293
  "融化的按钮": "melted button",
294
  "按钮融化": "melted button",
 
316
  return result
317
 
318
 
319
+ _PLACEHOLDER_ANCHORS = {
320
+ "梦里的那个细节",
321
+ "梦里的细节",
322
+ "那个细节",
323
+ "dream detail",
324
+ "that dream detail",
325
+ "that that dream detail",
326
+ "the dream detail",
327
+ "dream",
328
+ "dream fragment",
329
+ }
330
+
331
+
332
+ def _is_placeholder_anchor(text: str) -> bool:
333
+ clean = re.sub(r"\s+", " ", (text or "").strip().lower())
334
+ return not clean or clean in _PLACEHOLDER_ANCHORS or "dream detail" in clean or "梦里的那个细节" in clean
335
+
336
+
337
+ def _remove_placeholder_anchors(items: List[str]) -> List[str]:
338
+ return [item for item in items if not _is_placeholder_anchor(item)]
339
+
340
+
341
+ _VISUAL_CLUE_PREFIXES = (
342
+ "Scene:",
343
+ "Object:",
344
+ "Visible text:",
345
+ "Spatial relation:",
346
+ "Mood cue:",
347
+ "Uncertain detail:",
348
+ "Surprising detail:",
349
+ )
350
+
351
+
352
+ def _flatten_visual_value(value) -> List[str]:
353
+ if isinstance(value, str):
354
+ return [value]
355
+ if isinstance(value, list):
356
+ items: List[str] = []
357
+ for item in value:
358
+ items.extend(_flatten_visual_value(item))
359
+ return items
360
+ if isinstance(value, dict):
361
+ items = []
362
+ for item in value.values():
363
+ items.extend(_flatten_visual_value(item))
364
+ return items
365
+ return []
366
+
367
+
368
+ def _clean_visual_clue_text(clue: str) -> str:
369
+ clean = (clue or "").strip()
370
+ if not clean:
371
+ return ""
372
+ try:
373
+ parsed = json.loads(clean)
374
+ except json.JSONDecodeError:
375
+ parsed = None
376
+ if parsed is not None:
377
+ flattened = _flatten_visual_value(parsed)
378
+ clean = ", ".join(item for item in flattened if item.strip())
379
+ for prefix in _VISUAL_CLUE_PREFIXES:
380
+ if clean.lower().startswith(prefix.lower()):
381
+ clean = clean[len(prefix) :].strip()
382
+ break
383
+ clean = re.sub(r"\b(scene_summary|objects|visible_text|spatial_relations|mood_cues|uncertain_details|surprising_detail)\b", " ", clean)
384
+ clean = re.sub(r"[{}\[\]\"']", " ", clean)
385
+ clean = re.sub(r"\s+", " ", clean).strip(" .,:;!?()")
386
+ return clean
387
+
388
+
389
+ def _visual_anchor_candidates(intake: DreamIntake) -> List[str]:
390
+ candidates: List[str] = []
391
+ for clue in intake.visual_clues:
392
+ clean = _clean_visual_clue_text(clue)
393
+ if not clean:
394
+ continue
395
+ for phrase in re.split(r"\s*(?:,|;|,|;|/|\n)\s*", clean):
396
+ phrase = phrase.strip(" .,:;!?()[]{}")
397
+ if not phrase:
398
+ continue
399
+ if len(phrase) > 48:
400
+ phrase = phrase[:48].rsplit(" ", 1)[0].strip() or phrase[:48].strip()
401
+ candidates.append(phrase)
402
+ return _dedupe_preserve_order(candidates)
403
+
404
+
405
  def _extract_dream_anchors(intake: DreamIntake) -> List[str]:
406
  raw_text = " ".join(
407
  [
 
413
  )
414
  text = raw_text.lower()
415
  candidates: List[str] = []
416
+ visual_candidates = _visual_anchor_candidates(intake)
417
+ candidates.extend(visual_candidates[:3])
418
  for marker in _ZH_ANCHOR_MARKERS:
419
  if marker in raw_text:
420
  candidates.append(marker)
 
426
  r"paper|papers|promise|promises|window|windows|suitcase|suitcases|"
427
  r"clerk|clerks|sunrise|elevator|elevators|button|buttons|hallway|"
428
  r"gate|gates|floor|floors|stamp|stamps|number|numbers|exam|exams|"
429
+ r"pencil|pencils|train|trains|door|doors|subway|station|stations|snow|snowfield|"
430
+ r"hat|hats|phone|phones|water|shoe|shoes|"
431
+ r"stairwell|stairwells|room|rooms|key|keys|note|notes|rain|moon|moons|curtain|curtains|"
432
+ r"email|emails|meeting|meetings|deadline|deadlines|classroom|classrooms|teacher|teachers|"
433
+ r"assignment|assignments|homework|message|messages|airport|airports|glass|bird|birds|child|children|"
434
+ r"cat|cats|forest|forests|sign|signs|alley|alleys|mailbox|mailboxes|sketch|sketches|ceiling|ceilings"
435
  r")\b"
436
  )
437
  for match in pair_pattern.finditer(text):
438
  modifier, noun = match.groups()
439
+ normalized_noun = noun[:-1] if noun.endswith("s") and not noun.endswith("ss") else noun
440
+ phrase = f"{modifier} {normalized_noun}"
441
  if modifier not in _ANCHOR_STOPWORDS:
442
  candidates.append(phrase)
443
 
444
  noun_pattern = re.compile(
445
  r"\b(customs|suitcase|clerk|sunrise|elevator|button|hallway|gate|stamp|number|floor|"
446
+ r"exam|pencil|train|door|subway|station|snow|snowfield|hat|phone|water|shoe|stairwell|"
447
+ r"room|key|note|rain|moon|curtain|sleep|dream|email|meeting|deadline|classroom|teacher|"
448
+ r"assignment|homework|message|airport|glass|bird|child|cat|forest|sign|alley|mailbox|sketch|ceiling)\b"
449
  )
450
  candidates.extend(match.group(1) for match in noun_pattern.finditer(text))
451
+ candidates.extend(visual_candidates)
452
 
453
  return _dedupe_preserve_order(candidates)[:5]
454
 
 
457
  clean = text or ""
458
  for source, target in sorted(_ZH_TO_EN_PHRASES.items(), key=lambda item: len(item[0]), reverse=True):
459
  clean = clean.replace(source, target)
460
+ clean = re.sub(r"[\u4e00-\u9fff]+", " dream fragment ", clean)
461
  clean = re.sub(r"\s+", " ", clean).strip(" .,:;!?\"'()[]{}")
462
  return clean
463
 
 
490
  return cleaned
491
 
492
 
493
+ def _clean_placeholder_phrase(text: str) -> str:
494
+ clean = text or ""
495
+ clean = clean.replace("梦里的那个细节", "梦境片段").replace("那个细节", "梦境片段")
496
+ clean = re.sub(r"(?<=[A-Za-z])dream\s+detail", " dream detail", clean, flags=re.IGNORECASE)
497
+ clean = re.sub(r"dream\s+detail(?=[A-Za-z])", "dream detail ", clean, flags=re.IGNORECASE)
498
+ clean = re.sub(r"\b(?:the\s+)?that\s+dream\s+detail\b", "the dream fragment", clean, flags=re.IGNORECASE)
499
+ clean = re.sub(r"\bdream detail(?:dream detail|dream)*\b", "dream fragment", clean, flags=re.IGNORECASE)
500
+ clean = re.sub(r"\bthe\s+the\s+", "the ", clean, flags=re.IGNORECASE)
501
+ clean = re.sub(r"\s+", " ", clean)
502
+ return clean.strip()
503
+
504
+
505
  def _primary_anchor(intake: DreamIntake, language: str = "en") -> str:
506
  anchors = _anchors_for_language(intake, language)
507
  if anchors:
 
514
  return anchors[1] if len(anchors) > 1 else _primary_anchor(intake, language)
515
 
516
 
517
+ def _anchor_with_article(anchor: str) -> str:
518
+ clean = (anchor or "").strip()
519
+ if clean.lower().startswith(("the ", "a ", "an ")):
520
+ return clean
521
+ return f"the {clean}"
522
+
523
+
524
  def _title_anchor(text: str) -> str:
525
  return " ".join(part.capitalize() for part in text.split())
526
 
 
534
  clean = re.sub(r"\s+", " ", merged).strip()
535
  if len(clean) > 72:
536
  clean = clean[:69].rstrip() + "..."
537
+ if not _is_zh(language):
538
+ clean = _clean_placeholder_phrase(_english_anchor_text(clean))
539
  if not _is_zh(language):
540
  return clean if clean.lower().startswith(("i ", "you ")) else f"You dreamed about {clean}"
541
+ if clean.startswith(("你", "我", "I ", "i ")) or any(marker in clean[:12] for marker in ("梦见", "梦到")):
542
+ return clean
543
+ return f"你梦见{clean}"
544
 
545
 
546
  def _main_question_from_intake(intake: DreamIntake, language: str = "en") -> str:
547
  if intake.main_question.strip():
548
  return intake.main_question.strip()
549
+ task = _task_focus(intake.merged_text(), language)
550
+ if task:
551
+ if not _is_zh(language):
552
+ return f"How can I make the {task} feel smaller and more doable today?"
553
+ return f"今天怎样把「{task}」变成更小、更能开始的一步?"
554
+ if _has_prophecy_frame(intake.merged_text()):
555
+ if not _is_zh(language):
556
+ return "How can I treat this dream as a feeling to notice, not a prediction?"
557
+ return "我怎样把这个梦当作一种感受来照顾,而不是当成预兆?"
558
  primary = _primary_anchor(intake, language)
559
  if not _is_zh(language):
560
+ return f"What might {_anchor_with_article(primary)} be asking me to notice today?"
561
  return f"这个梦里的「{primary}」可能在提醒我什么?"
562
 
563
 
 
566
  secondary = _secondary_anchor(intake, language)
567
  if not _is_zh(language):
568
  return (
569
+ f"Maybe this dream is not giving you a fixed answer. It is placing {_anchor_with_article(primary)} "
570
+ f"beside {_anchor_with_article(secondary)} so you can notice one small stuck point today."
571
  )
572
  return (
573
  f"也许这个梦不是在给你一个确定答案,而是把「{primary}」和「{secondary}」放到一起,"
 
579
  primary = _primary_anchor(intake, language)
580
  if not _is_zh(language):
581
  return (
582
+ f"For today, borrow one action from {_anchor_with_article(primary)}: open the task, write only the first line, "
583
  "and let that be enough for now."
584
  )
585
  return f"今天先从「{primary}」借一个动作:只做最小的第一步,不急着把整件事完成。"
 
590
  if _is_zh(language):
591
  if "邮件" in lowered or "email" in lowered:
592
  return "给自己 5 分钟,只打开那封邮件,写��第一句话;今天不要求立刻发出。"
593
+ if "消息" in lowered or "发消息" in lowered:
594
+ return "给自己 5 分钟,只写一条诚实的进度消息草稿;今天先不要求把整件事做完。"
595
+ if "作业" in lowered or "草稿" in lowered:
596
+ return "给自己 5 分钟,只打开草稿,写下下一小段或一个待补的标题。"
597
  return ""
598
  if "email" in lowered or "message" in lowered:
599
  return "Set a five-minute timer, open the email, and write only the first sentence. You do not have to send it yet."
600
+ if "assignment" in lowered or "homework" in lowered or "draft" in lowered:
601
+ return "Set a five-minute timer, open the draft, and write only the next small heading or first sentence."
602
+ if "presentation" in lowered or "speech" in lowered or "rehearse" in lowered:
603
+ return "Set a five-minute timer and rehearse only the first minute, then stop and write one note for tomorrow."
604
+ if "deadline" in lowered or "application" in lowered:
605
+ return "Set a five-minute timer, open the application checklist, and mark only the next missing item."
606
+ if "apolog" in lowered:
607
+ return "Set a five-minute timer and draft one honest apology sentence without sending it yet."
608
  return ""
609
 
610
 
 
613
  if _is_zh(language):
614
  if "邮件" in lowered or "email" in lowered:
615
  return f"今天把「{anchor}」当成允许慢慢开始的按钮:只打开那封邮件,先写第一句话。"
616
+ if "消息" in lowered or "发消息" in lowered:
617
+ return f"今天把「{anchor}」当成一个小门牌:先给对方发一条进度消息,不要求马上完成全部。"
618
+ if "作业" in lowered or "草稿" in lowered:
619
+ return f"今天把「{anchor}」当成一个可以靠近的教室门口:只打开草稿,补上下一小段。"
620
  return ""
621
  if "email" in lowered or "message" in lowered:
622
  return (
623
  f"For today, treat the {anchor} as permission to start gently: "
624
  "open the overdue email and write only the first sentence."
625
  )
626
+ if "assignment" in lowered or "homework" in lowered or "draft" in lowered:
627
+ return (
628
+ f"For today, treat the {anchor} as a smaller doorway: open the draft "
629
+ "and write only the next tiny piece."
630
+ )
631
+ if "presentation" in lowered or "speech" in lowered or "rehearse" in lowered:
632
+ return f"For today, let the {anchor} narrow the work: rehearse only the first minute of the presentation."
633
+ if "deadline" in lowered or "application" in lowered:
634
+ return f"For today, let the {anchor} become a checklist: open the application and choose one missing item."
635
+ if "apolog" in lowered:
636
+ return f"For today, let the {anchor} become one repair step: draft a single apology sentence."
637
  return ""
638
 
639
 
 
644
  return f"也许「{anchor}」不是在催你立刻完成什么,而是在提醒你:那封邮件可以先从一句话开始。"
645
  if "消息" in lowered or "message" in lowered:
646
  return f"也许「{anchor}」不是在催你立刻回应什么,而是在提醒你:那条消息可以先从一句草稿开始。"
647
+ if "作业" in lowered or "草稿" in lowered:
648
+ return f"也许「{anchor}」不是在说你已经来不及,而是在提醒你:草稿可以先从下一小段开始。"
649
  return ""
650
  if "email" in lowered:
651
  return (
 
657
  f"Maybe the {anchor} is not asking you to answer every message at once. "
658
  "It is pointing to the gentler threshold: opening one thread and drafting one first sentence."
659
  )
660
+ if "assignment" in lowered or "homework" in lowered or "draft" in lowered:
661
+ return (
662
+ f"Maybe the {anchor} is not asking you to finish the whole assignment at once. "
663
+ "It is pointing to the gentler threshold: opening the draft and adding one small piece."
664
+ )
665
+ if "presentation" in lowered or "speech" in lowered or "rehearse" in lowered:
666
+ return (
667
+ f"Maybe the {anchor} is not asking you to perfect the whole presentation tonight. "
668
+ "It is pointing to the gentler threshold: rehearsing the first minute once."
669
+ )
670
+ if "deadline" in lowered or "application" in lowered:
671
+ return (
672
+ f"Maybe the {anchor} is not asking you to finish the whole application in one push. "
673
+ "It is pointing to the gentler threshold: finding the next missing item."
674
+ )
675
+ if "apolog" in lowered:
676
+ return (
677
+ f"Maybe the {anchor} is not asking you to repair everything at once. "
678
+ "It is pointing to the gentler threshold: drafting one honest sentence."
679
+ )
680
  return ""
681
 
682
 
 
697
 
698
  def _text_uses_anchor(text: str, anchors: List[str]) -> bool:
699
  clean = (text or "").lower()
700
+ for anchor in anchors:
701
+ item = anchor.lower().strip()
702
+ if not item or _is_placeholder_anchor(item):
703
+ continue
704
+ if item in clean:
705
+ return True
706
+ tokens = [token for token in re.split(r"[\s,,。::;;、]+", item) if len(token) >= 3]
707
+ if any(token in clean for token in tokens):
708
+ return True
709
+ return False
710
 
711
 
712
  def _is_generic_visitor_name(text: str, intake: DreamIntake) -> bool:
 
855
  )
856
 
857
 
858
+ def _question_for_declared_real_task(intake: DreamIntake, language: str = "en") -> str:
859
+ merged = intake.merged_text().lower()
860
+ primary = _primary_anchor(intake, language)
861
+ prophecy_question = _question_for_prophecy_frame(intake, language)
862
+ if prophecy_question:
863
+ return prophecy_question
864
+ if not _is_zh(language):
865
+ if "email" in merged:
866
+ return f"When the {primary} shows up beside the overdue email, what would make opening that email feel smaller today?"
867
+ if any(term in merged for term in ["assignment", "homework", "draft"]):
868
+ return f"When the {primary} shows up beside the assignment, what is the smallest useful piece you could start today?"
869
+ if any(term in merged for term in ["presentation", "speech", "rehearse"]):
870
+ return f"When the {primary} shows up beside the presentation, what would make the first minute easier to rehearse?"
871
+ if any(term in merged for term in ["deadline", "application"]):
872
+ return f"When the {primary} shows up beside the deadline, what is the next missing item you can safely choose?"
873
+ if "apolog" in merged:
874
+ return f"When the {primary} shows up beside the apology, what is one honest sentence you could draft first?"
875
+ if "message" in merged:
876
+ return f"When the {primary} shows up beside that message, what is one sentence you could safely draft today?"
877
+ return ""
878
+ if "邮件" in merged or "email" in merged:
879
+ return f"当「{primary}」和那封邮件连在一起时,今天怎样才能让打开它这件事变小一点?"
880
+ if "作业" in merged or "草稿" in merged:
881
+ return f"当「{primary}」和作业连在一起时,今天最小、最安全的一步可以是什么?"
882
+ if "演讲" in merged or "汇报" in merged:
883
+ return f"当「{primary}」和演讲连在一起时,今晚只排练开头一分钟可以怎么做?"
884
+ if "申请" in merged or "截止" in merged:
885
+ return f"当「{primary}」和截止时间连在一起时,今天可以先确认哪一个缺口?"
886
+ if "道歉" in merged:
887
+ return f"当「{primary}」和道歉连在一起时,今天可以先写哪一句真诚草稿?"
888
+ if "消息" in merged or "发消息" in merged:
889
+ return f"当「{primary}」和那条消息连在一起时,今天可以先写哪一句草稿?"
890
+ return ""
891
+
892
+
893
+ def _has_prophecy_frame(text: str) -> bool:
894
+ lowered = (text or "").lower()
895
+ return any(
896
+ term in lowered
897
+ for term in [
898
+ "sign something bad",
899
+ "bad will happen",
900
+ "omen",
901
+ "prophecy",
902
+ "predict",
903
+ "预兆",
904
+ "征兆",
905
+ "坏事",
906
+ "会不会发生",
907
+ ]
908
+ )
909
+
910
+
911
+ def _question_for_prophecy_frame(intake: DreamIntake, language: str = "en") -> str:
912
+ if not _has_prophecy_frame(intake.merged_text()):
913
+ return ""
914
+ primary = _primary_anchor(intake, language)
915
+ if not _is_zh(language):
916
+ return (
917
+ f"Rather than treating the {primary} as a prediction, what feeling did it leave in your body "
918
+ "when you woke up?"
919
+ )
920
+ return f"先不把「{primary}」当成预兆。醒来时,它在你身体里留下的最明显感受是什么?"
921
+
922
+
923
+ def _task_focus(text: str, language: str = "en") -> str:
924
+ lowered = (text or "").lower()
925
+ if _is_zh(language):
926
+ for term in ["演讲", "汇报", "申请", "截止", "道歉", "作业", "草稿", "邮件", "消息"]:
927
+ if term in lowered:
928
+ return term
929
+ return ""
930
+ task_terms = [
931
+ ("presentation", ["presentation", "speech", "rehearse"]),
932
+ ("application deadline", ["application", "deadline"]),
933
+ ("apology", ["apolog"]),
934
+ ("assignment", ["assignment", "homework", "draft"]),
935
+ ("email", ["email"]),
936
+ ("message", ["message"]),
937
+ ]
938
+ for label, terms in task_terms:
939
+ if any(term in lowered for term in terms):
940
+ return label
941
+ return ""
942
+
943
+
944
+ def _is_low_context_intake(intake: DreamIntake) -> bool:
945
+ merged = intake.merged_text()
946
+ dream_text = intake.dream_text.strip()
947
+ if not dream_text:
948
+ return False
949
+ cjk_count = sum(1 for char in dream_text if "\u4e00" <= char <= "\u9fff")
950
+ word_count = len(re.findall(r"[A-Za-z0-9'-]+", dream_text))
951
+ return (word_count and word_count <= 7) or (cjk_count and cjk_count <= 12)
952
+
953
+
954
+ def _is_skip_answer(answers: str, language: str = "en") -> bool:
955
+ lowered = (answers or "").lower()
956
+ return "user chose to skip" in lowered or "用户选择跳过" in lowered
957
+
958
+
959
  def _grounded_followup_question(intake: DreamIntake, language: str = "en") -> str:
960
  primary = _primary_anchor(intake, language)
961
  if not _is_zh(language):
 
1046
  answers: Optional[List[str]] = None,
1047
  language: str = "en",
1048
  ) -> DreamQAState:
1049
+ anchors = _remove_placeholder_anchors(_anchors_for_language(intake, language))
1050
  return DreamQAState(
1051
  dream_summary=_summary_from_intake(intake, language),
1052
  main_question=_main_question_from_intake(intake, language),
 
1059
 
1060
  def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = "", language: str = "en") -> TodayTipCard:
1061
  polished = card.model_copy(deep=True)
1062
+ answer_lines = [line.strip() for line in (answers or "").splitlines() if line.strip()]
1063
+ if answer_lines:
1064
+ polished.user_answers = answer_lines
1065
+ intake_anchors = _remove_placeholder_anchors(_anchors_for_language(intake, language))
1066
+ card_anchors = _remove_placeholder_anchors(
1067
  polished.dream_anchors
1068
  if _is_zh(language)
1069
  else _dedupe_preserve_order([_english_anchor_text(anchor) for anchor in polished.dream_anchors])
 
1073
  else:
1074
  anchors = card_anchors or intake_anchors
1075
  if not anchors:
1076
+ anchors = _remove_placeholder_anchors([_primary_anchor(intake, language)])
1077
+ if not anchors:
1078
+ anchors = ["梦境片段" if _is_zh(language) else "dream fragment"]
1079
  polished.dream_anchors = anchors
1080
+ for field in (
1081
+ "dream_summary",
1082
+ "main_question",
1083
+ "interpretation",
1084
+ "today_tip",
1085
+ "tiny_action",
1086
+ "caring_note",
1087
+ "safety_note",
1088
+ ):
1089
+ setattr(polished, field, _clean_placeholder_phrase(getattr(polished, field)))
1090
+ if not polished.dream_summary.strip() or _is_placeholder_anchor(polished.dream_summary) or not _text_uses_anchor(polished.dream_summary, anchors):
1091
  polished.dream_summary = _summary_from_intake(intake, language)
1092
+ if (
1093
+ not polished.main_question.strip()
1094
+ or _is_placeholder_anchor(polished.main_question)
1095
+ or not _text_uses_anchor(polished.main_question, anchors)
1096
+ ):
1097
  polished.main_question = _main_question_from_intake(intake, language)
1098
  answer_interpretation = _answer_based_interpretation(answers, _answer_bridge_anchor(anchors), language)
1099
  if answer_interpretation:
1100
  polished.interpretation = answer_interpretation
1101
+ elif _has_prophecy_frame(intake.merged_text()):
1102
+ anchor = _answer_bridge_anchor(anchors)
1103
+ polished.interpretation = (
1104
+ f"Maybe the {anchor} is best treated as a fear-shaped image, not as evidence that something bad will happen."
1105
+ if not _is_zh(language)
1106
+ else f"也许「{anchor}」更适合被当作一种害怕的画面,而不是坏事会发生的证据。"
1107
+ )
1108
+ elif _is_low_context_intake(intake) and _is_skip_answer(answers, language):
1109
+ anchor = _answer_bridge_anchor(anchors)
1110
+ polished.interpretation = (
1111
+ f"With only a few details, I would keep this very light: the {anchor} is one clue to notice, not enough for a firm reading."
1112
+ if not _is_zh(language)
1113
+ else f"目前线索很少,先把「{anchor}」当作一个可以继续补充的线索,而不是确定解读。"
1114
+ )
1115
  elif not polished.interpretation.strip() or not _anchor_in_text(polished.interpretation, anchors):
1116
  polished.interpretation = _fallback_interpretation(intake, language)
1117
  generic_tip_markers = ["drink water", "hydrate", "多休息", "保持积极", "take a walk"]
1118
  answer_tip = _answer_based_today_tip(answers, anchors[0], language)
1119
+ if answer_tip:
 
 
 
1120
  polished.today_tip = answer_tip
1121
+ elif _has_prophecy_frame(intake.merged_text()):
1122
+ anchor = anchors[0]
1123
+ polished.today_tip = (
1124
+ f"For today, do not test whether the {anchor} is a sign. Name one ordinary worry it resembles, then choose one small calming step."
1125
+ if not _is_zh(language)
1126
+ else f"今天先不要验证「{anchor}」是不是预兆;写下它像哪一种普通担心,再选一个很小的安定动作。"
1127
+ )
1128
+ elif _is_low_context_intake(intake) and _is_skip_answer(answers, language):
1129
+ anchor = anchors[0]
1130
+ polished.today_tip = (
1131
+ f"For today, keep the {anchor} as a note, not a conclusion: add one concrete detail before you act on the dream."
1132
+ if not _is_zh(language)
1133
+ else f"今天先把「{anchor}」当作记录,不当作结论:再补一个具体细节,然后再行动。"
1134
+ )
1135
  elif (
1136
  not polished.today_tip.strip()
1137
  or any(marker in polished.today_tip.lower() for marker in generic_tip_markers)
1138
+ or _is_placeholder_anchor(polished.today_tip)
1139
  or not _anchor_in_text(polished.today_tip, anchors)
1140
  ):
1141
  polished.today_tip = _grounded_today_tip(intake, language)
1142
  hard_action_markers = ["address it immediately", "fix it immediately", "solve it immediately"]
1143
  answer_action = _answer_based_tiny_action(answers, language)
1144
+ if answer_action:
1145
+ polished.tiny_action = answer_action
1146
+ elif _has_prophecy_frame(intake.merged_text()):
1147
+ polished.tiny_action = (
1148
+ "Spend five minutes writing: what did I feel, what ordinary worry could it echo, and what can I do safely today?"
1149
+ if not _is_zh(language)
1150
+ else "用 5 分钟写下:我醒来时感觉到了什么?它像哪种普通担心?今天我能安全做哪一小步?"
1151
+ )
1152
+ elif _is_low_context_intake(intake) and _is_skip_answer(answers, language):
1153
+ polished.tiny_action = (
1154
+ "Add one missing detail: color, body feeling, repeated object, or what happened just before waking."
1155
+ if not _is_zh(language)
1156
+ else "补一个缺失细节:颜色、身体感受、重复物件,或醒来前最后发生的事。"
1157
+ )
1158
+ elif (
1159
  not polished.tiny_action.strip()
1160
+ or _is_placeholder_anchor(polished.tiny_action)
1161
+ or not _anchor_in_text(polished.tiny_action, anchors)
1162
  or any(marker in polished.tiny_action.lower() for marker in hard_action_markers)
 
1163
  ):
 
 
1164
  if _is_zh(language):
1165
  polished.tiny_action = f"用 5 分钟写下:今天和「{anchors[0]}」有关的第一小步是什么?"
1166
  else:
 
1171
  if _is_zh(language)
1172
  else "You do not have to solve the whole dream this morning; noticing one detail is enough."
1173
  )
1174
+ elif any(
1175
+ marker in polished.caring_note.lower()
1176
+ for marker in ["does not indicate any real-life concerns", "does not indicate any real life concerns"]
1177
+ ):
1178
+ polished.caring_note = (
1179
+ "你刚才提到的现实困扰值得被温柔对待;今天先照顾一个很小的入口。"
1180
+ if _is_zh(language)
1181
+ else "The real concern you named deserves gentle handling; today, start with one small doorway into it."
1182
+ )
1183
+ elif not _is_zh(language) and any(marker in polished.caring_note.lower() for marker in ["whole building", "one floor at a time"]):
1184
+ polished.caring_note = "You do not have to solve the whole dream this morning; start by noticing one detail and one small next step."
1185
+ elif _is_zh(language) and "所有楼层" in polished.caring_note:
1186
+ polished.caring_note = "你不需要一醒来就解释完整个梦;先照顾一个细节和一个很小的下一步就好。"
1187
  merged = "\n".join([intake.merged_text(), answers or ""])
1188
+ polished.safety_note = safety_note(language) if needs_escalation(merged) else ""
1189
  if not _is_zh(language):
1190
  polished = _clean_english_today_tip_language(polished)
1191
  return polished
1192
 
1193
 
1194
+ def generate_today_tip(
1195
+ intake: DreamIntake,
1196
+ answers: str,
1197
+ text_client,
1198
+ language: str = "en",
1199
+ followup_questions: Optional[List[str]] = None,
1200
+ ) -> TodayTipCard:
1201
  language = _normalize_language(language)
1202
+ qa_state = build_qa_state(
1203
+ intake,
1204
+ questions=followup_questions or [],
1205
+ answers=[answer for answer in [answers] if answer],
1206
+ language=language,
1207
+ )
1208
  prompt = today_tip_prompt(qa_state, language=language)
1209
  try:
1210
  if hasattr(text_client, "generate_today_tip"):
 
1233
  interpretation=_fallback_interpretation(intake, language),
1234
  today_tip=_grounded_today_tip(intake, language),
1235
  )
1236
+ card.followup_questions = qa_state.followup_questions
1237
+ if qa_state.user_answers:
1238
+ card.user_answers = qa_state.user_answers
1239
  return _polish_today_tip(card, intake, answers, language)
1240
 
1241
 
 
1302
  def create_session(language: str = "en") -> CustomsSession:
1303
  language = _normalize_language(language)
1304
  return CustomsSession(
1305
+ language=language,
1306
  events=[
1307
  TimelineEvent(
1308
  role="system",
 
1370
  ) -> CustomsSession:
1371
  language = _normalize_language(language)
1372
  next_session = session.model_copy(deep=True)
1373
+ next_session.language = language
1374
  added_items: List[EvidenceItem] = []
1375
 
1376
  if dream_text and dream_text.strip():
 
1494
  def ask_questions(session: CustomsSession, text_client, force_another: bool = False, language: str = "en") -> CustomsSession:
1495
  language = _normalize_language(language)
1496
  next_session = session.model_copy(deep=True)
1497
+ next_session.language = language
1498
  if not next_session.intake.merged_text():
1499
  next_session.phase = "error"
1500
  next_session.events.append(
 
1516
  )
1517
  negotiation = text_client.generate_negotiation(prompt)
1518
  questions = [question for question in negotiation.get("questions", []) if question]
1519
+ task_question = _question_for_declared_real_task(next_session.intake, language)
1520
+ if task_question:
1521
+ questions = [task_question] + [question for question in questions if question != task_question]
1522
  fresh_questions = [question for question in questions if question not in next_session.question_history]
1523
  if force_another and not fresh_questions:
1524
  fresh_questions = [
 
1566
  def answer_question(session: CustomsSession, answer: str, language: str = "en") -> CustomsSession:
1567
  language = _normalize_language(language)
1568
  next_session = session.model_copy(deep=True)
1569
+ next_session.language = language
1570
  if not answer or not answer.strip():
1571
  next_session.phase = "error"
1572
  next_session.events.append(
 
1586
  def skip_question(session: CustomsSession, language: str = "en") -> CustomsSession:
1587
  language = _normalize_language(language)
1588
  next_session = session.model_copy(deep=True)
1589
+ next_session.language = language
1590
  skip_text = "用户选择跳过这个追问。" if _is_zh(language) else "The user chose to skip this question."
1591
  next_session.answer_history.append(skip_text)
1592
  next_session.phase = "ask"
 
1600
  def finish_today_tip(session: CustomsSession, text_client, language: str = "en") -> CustomsSession:
1601
  language = _normalize_language(language)
1602
  next_session = session.model_copy(deep=True)
1603
+ next_session.language = language
1604
  if not next_session.intake.merged_text():
1605
  next_session.phase = "error"
1606
  next_session.events.append(
 
1615
  )
1616
  return next_session
1617
  answers = next_session.answers_text()
1618
+ card = generate_today_tip(
1619
+ next_session.intake,
1620
+ answers,
1621
+ text_client,
1622
+ language=language,
1623
+ followup_questions=next_session.question_history,
1624
+ )
1625
  next_session.qa_state = build_qa_state(
1626
  next_session.intake, next_session.question_history, next_session.answer_history, language=language
1627
  )
dream_customs/safety.py CHANGED
@@ -1,17 +1,38 @@
1
  ESCALATION_TERMS = (
2
  "hurt myself",
3
  "kill myself",
 
 
 
 
 
 
4
  "suicide",
5
  "self-harm",
6
  "hurt someone",
7
  "many nights",
 
 
 
 
8
  "cannot function",
9
  "panic attack",
10
  "想伤害自己",
11
  "自杀",
12
  "自残",
 
 
 
 
 
 
 
13
  "伤害别人",
14
  "很多天睡不着",
 
 
 
 
15
  "无法正常生活",
16
  "无法生活",
17
  )
@@ -22,7 +43,13 @@ def needs_escalation(text: str) -> bool:
22
  return any(term in lowered for term in ESCALATION_TERMS)
23
 
24
 
25
- def safety_note() -> str:
 
 
 
 
 
 
26
  return (
27
  "This dream sounds heavier than a playful reflection tool should handle. "
28
  "If you feel unsafe, cannot sleep for many nights, or worry you may hurt "
 
1
  ESCALATION_TERMS = (
2
  "hurt myself",
3
  "kill myself",
4
+ "do not want to wake up",
5
+ "don't want to wake up",
6
+ "didn't want to wake up",
7
+ "hopeless",
8
+ "can't go on",
9
+ "cannot go on",
10
  "suicide",
11
  "self-harm",
12
  "hurt someone",
13
  "many nights",
14
+ "three nights",
15
+ "3 nights",
16
+ "can't sleep",
17
+ "cannot sleep",
18
  "cannot function",
19
  "panic attack",
20
  "想伤害自己",
21
  "自杀",
22
  "自残",
23
+ "不想醒来",
24
+ "不想活",
25
+ "活不下去",
26
+ "轻生",
27
+ "绝望",
28
+ "撑不住",
29
+ "崩溃",
30
  "伤害别人",
31
  "很多天睡不着",
32
+ "连续3晚",
33
+ "连续 3 晚",
34
+ "连续三晚",
35
+ "连续很多天",
36
  "无法正常生活",
37
  "无法生活",
38
  )
 
43
  return any(term in lowered for term in ESCALATION_TERMS)
44
 
45
 
46
+ def safety_note(language: str = "en") -> str:
47
+ if language == "zh":
48
+ return (
49
+ "这段梦和醒来后的感受听起来已经超过了一个轻量梦境反思工具适合独自处理的范围。"
50
+ "如果你感到不安全、连续很多天睡不着,或担心自己可能伤害自己或别人,请现在联系一个可信任的人,"
51
+ "或寻求专业支持。"
52
+ )
53
  return (
54
  "This dream sounds heavier than a playful reflection tool should handle. "
55
  "If you feel unsafe, cannot sleep for many nights, or worry you may hurt "
dream_customs/schema.py CHANGED
@@ -219,6 +219,7 @@ class TimelineEvent(BaseModel):
219
 
220
  class CustomsSession(BaseModel):
221
  phase: SessionPhase = "empty"
 
222
  intake: DreamQuestionIntake = Field(default_factory=DreamQuestionIntake)
223
  qa_state: DreamQAState = Field(default_factory=DreamQAState)
224
  evidence_items: list[EvidenceItem] = Field(default_factory=list)
 
219
 
220
  class CustomsSession(BaseModel):
221
  phase: SessionPhase = "empty"
222
+ language: str = "en"
223
  intake: DreamQuestionIntake = Field(default_factory=DreamQuestionIntake)
224
  qa_state: DreamQAState = Field(default_factory=DreamQAState)
225
  evidence_items: list[EvidenceItem] = Field(default_factory=list)
dream_customs/ui/actions.py CHANGED
@@ -15,10 +15,24 @@ from dream_customs.pipeline import (
15
  skip_question,
16
  )
17
  from dream_customs.render import render_today_tip_card
18
- from dream_customs.schema import CustomsSession, TodayTipCard
19
  from dream_customs.ui.copy import copy_for, normalize_language
20
 
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  def _state_json(session: CustomsSession) -> str:
23
  return json.dumps(session.model_dump(mode="json"), ensure_ascii=False)
24
 
@@ -44,13 +58,40 @@ def _trim_to_one_visible_question(session: CustomsSession, previous_count: int)
44
 
45
 
46
  def _card_plain_text(card: TodayTipCard, language: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  if language == "zh":
48
- return card.to_plain_text()
 
 
 
 
 
 
 
 
 
49
  lines = [
50
  "Today Tip",
51
  f"Dream summary: {card.dream_summary}",
52
  f"Question: {card.main_question}",
53
  f"Dream anchors: {', '.join(card.dream_anchors)}",
 
 
54
  f"Interpretation: {card.interpretation}",
55
  f"Today Tip: {card.today_tip}",
56
  ]
@@ -60,6 +101,10 @@ def _card_plain_text(card: TodayTipCard, language: str) -> str:
60
  lines.append(f"Caring note: {card.caring_note}")
61
  if card.safety_note:
62
  lines.append(f"Safety note: {card.safety_note}")
 
 
 
 
63
  return "\n".join(lines)
64
 
65
 
@@ -79,11 +124,23 @@ def _view_payload(
79
  **settings,
80
  ) -> Dict[str, Any]:
81
  language = normalize_language(language)
82
- copy = copy_for(language)
83
- card = session.sealed_tip or session.draft_tip
 
84
  error = _latest_error(session)
85
- status = "error" if error else "tip" if session.sealed_tip else "ask" if session.question_history else "record"
86
- return {
 
 
 
 
 
 
 
 
 
 
 
87
  "status": status,
88
  "phase": session.phase,
89
  "language": language,
@@ -94,8 +151,34 @@ def _view_payload(
94
  "card_html": _render_today_pass(card, language) if card else "",
95
  "error": error,
96
  "notice": _notice_for_status(status, error, language),
 
 
 
 
 
 
 
 
 
 
97
  "debug": json.loads(_debug_json(session, text_backend, vision_backend, **settings)),
98
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
 
101
  def _view(
@@ -115,7 +198,10 @@ def _view(
115
  def _notice_for_status(status: str, error: str = "", language: str = "en") -> str:
116
  copy = copy_for(language)
117
  if status == "error":
118
- return error or copy["notice_error"]
 
 
 
119
  if status == "ask":
120
  return copy["notice_ask"]
121
  if status == "tip":
@@ -143,7 +229,23 @@ def submit_dream_action(
143
  language: str = "en",
144
  **settings,
145
  ) -> Tuple[str, str]:
146
- language = normalize_language(language)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  text_client, vision_client, asr_client = _clients(text_backend, vision_backend, **settings)
148
  session = add_evidence(
149
  create_session(language=language),
@@ -169,8 +271,9 @@ def skip_to_card_action(
169
  language: str = "en",
170
  **settings,
171
  ) -> Tuple[str, str]:
172
- language = normalize_language(language)
173
- session = skip_question(_session_from_state(state), language=language)
 
174
  return _seal_view(session, text_backend, vision_backend, language=language, **settings)
175
 
176
 
@@ -182,8 +285,9 @@ def answer_to_card_action(
182
  language: str = "en",
183
  **settings,
184
  ) -> Tuple[str, str]:
185
- language = normalize_language(language)
186
- session = answer_question(_session_from_state(state), answer or "", language=language)
 
187
  if session.phase == "error":
188
  return _view(session, text_backend, vision_backend, language=language, **settings)
189
  return _seal_view(session, text_backend, vision_backend, language=language, **settings)
@@ -197,8 +301,8 @@ def revise_card_action(
197
  language: str = "en",
198
  **settings,
199
  ) -> Tuple[str, str]:
200
- language = normalize_language(language)
201
  session = _session_from_state(state)
 
202
  if session.sealed_tip and not session.draft_tip:
203
  session.draft_tip = session.sealed_tip
204
  text_client, _vision_client, _asr_client = _clients(text_backend, vision_backend, **settings)
@@ -223,5 +327,6 @@ def _seal_view(
223
  **settings,
224
  ) -> Tuple[str, str]:
225
  text_client, _vision_client, _asr_client = _clients(text_backend, vision_backend, **settings)
 
226
  session = finish_today_tip(session, text_client, language=language)
227
  return _view(session, text_backend, vision_backend, language=language, **settings)
 
15
  skip_question,
16
  )
17
  from dream_customs.render import render_today_tip_card
18
+ from dream_customs.schema import CustomsSession, TimelineEvent, TodayTipCard
19
  from dream_customs.ui.copy import copy_for, normalize_language
20
 
21
 
22
+ def _looks_mostly_chinese(text: str) -> bool:
23
+ clean = text or ""
24
+ cjk_count = sum(1 for char in clean if "\u4e00" <= char <= "\u9fff")
25
+ latin_count = sum(1 for char in clean if char.isascii() and char.isalpha())
26
+ return cjk_count >= 4 and cjk_count >= latin_count
27
+
28
+
29
+ def _resolve_language_for_input(language: str, dream_text: str = "") -> str:
30
+ normalized = normalize_language(language)
31
+ if normalized == "en" and _looks_mostly_chinese(dream_text):
32
+ return "zh"
33
+ return normalized
34
+
35
+
36
  def _state_json(session: CustomsSession) -> str:
37
  return json.dumps(session.model_dump(mode="json"), ensure_ascii=False)
38
 
 
58
 
59
 
60
  def _card_plain_text(card: TodayTipCard, language: str) -> str:
61
+ structured = json.dumps(
62
+ {
63
+ "dream_summary": card.dream_summary,
64
+ "main_question": card.main_question,
65
+ "dream_anchors": card.dream_anchors,
66
+ "followup_questions": card.followup_questions,
67
+ "user_answers": card.user_answers,
68
+ "interpretation": card.interpretation,
69
+ "today_tip": card.today_tip,
70
+ "tiny_action": card.tiny_action,
71
+ "caring_note": card.caring_note,
72
+ "safety_note": card.safety_note,
73
+ },
74
+ ensure_ascii=False,
75
+ indent=2,
76
+ )
77
  if language == "zh":
78
+ text = card.to_plain_text()
79
+ if card.followup_questions:
80
+ text += "\n追问记录: " + " / ".join(card.followup_questions)
81
+ if card.user_answers:
82
+ text += "\n用户回答: " + " / ".join(card.user_answers)
83
+ if card.followup_questions and card.user_answers:
84
+ text += "\n追问线索: 你的回答已进入今日小 Tips 的解读与行动建议。"
85
+ text += "\n模型说明: 文本推理由 MiniCPM5-1B 路线生成;图片线索由 MiniCPM-V-4.6 路线理解。"
86
+ text += f"\n\n结构化结果 JSON:\n{structured}"
87
+ return text
88
  lines = [
89
  "Today Tip",
90
  f"Dream summary: {card.dream_summary}",
91
  f"Question: {card.main_question}",
92
  f"Dream anchors: {', '.join(card.dream_anchors)}",
93
+ f"Follow-up questions: {' / '.join(card.followup_questions)}",
94
+ f"User answers: {' / '.join(card.user_answers)}",
95
  f"Interpretation: {card.interpretation}",
96
  f"Today Tip: {card.today_tip}",
97
  ]
 
101
  lines.append(f"Caring note: {card.caring_note}")
102
  if card.safety_note:
103
  lines.append(f"Safety note: {card.safety_note}")
104
+ if card.followup_questions and card.user_answers:
105
+ lines.append("Reasoning trail: Your follow-up answer shaped the interpretation and Today Tip.")
106
+ lines.append("Model note: text via MiniCPM5-1B route; visual clues via MiniCPM-V-4.6 route.")
107
+ lines.extend(["", "Structured result JSON:", structured])
108
  return "\n".join(lines)
109
 
110
 
 
124
  **settings,
125
  ) -> Dict[str, Any]:
126
  language = normalize_language(language)
127
+ session_language = normalize_language(getattr(session, "language", language))
128
+ if session_language != language and session.phase != "empty":
129
+ language = session_language
130
  error = _latest_error(session)
131
+ if error:
132
+ status = "error"
133
+ elif session.phase == "ask" and session.question_history:
134
+ status = "ask"
135
+ elif session.sealed_tip:
136
+ status = "tip"
137
+ elif session.question_history:
138
+ status = "ask"
139
+ else:
140
+ status = "record"
141
+ copy = copy_for(language)
142
+ card = None if status == "ask" else session.sealed_tip or session.draft_tip
143
+ payload = {
144
  "status": status,
145
  "phase": session.phase,
146
  "language": language,
 
151
  "card_html": _render_today_pass(card, language) if card else "",
152
  "error": error,
153
  "notice": _notice_for_status(status, error, language),
154
+ "dream_summary": "",
155
+ "main_question": "",
156
+ "dream_anchors": [],
157
+ "followup_questions": list(session.question_history),
158
+ "user_answers": list(session.answer_history),
159
+ "interpretation": "",
160
+ "today_tip": "",
161
+ "tiny_action": "",
162
+ "caring_note": "",
163
+ "safety_note": "",
164
  "debug": json.loads(_debug_json(session, text_backend, vision_backend, **settings)),
165
  }
166
+ if card:
167
+ payload.update(
168
+ {
169
+ "dream_summary": card.dream_summary,
170
+ "main_question": card.main_question,
171
+ "dream_anchors": card.dream_anchors,
172
+ "followup_questions": card.followup_questions,
173
+ "user_answers": card.user_answers,
174
+ "interpretation": card.interpretation,
175
+ "today_tip": card.today_tip,
176
+ "tiny_action": card.tiny_action,
177
+ "caring_note": card.caring_note,
178
+ "safety_note": card.safety_note,
179
+ }
180
+ )
181
+ return payload
182
 
183
 
184
  def _view(
 
198
  def _notice_for_status(status: str, error: str = "", language: str = "en") -> str:
199
  copy = copy_for(language)
200
  if status == "error":
201
+ base = error or copy["notice_error"]
202
+ if language == "zh":
203
+ return f"{base} 你可以保留文字再点一次继续,或点击重新开始;Text-only 路径仍可用。"
204
+ return f"{base} You can keep the text and try Continue again, or start over; the text-only path still works."
205
  if status == "ask":
206
  return copy["notice_ask"]
207
  if status == "tip":
 
229
  language: str = "en",
230
  **settings,
231
  ) -> Tuple[str, str]:
232
+ language = _resolve_language_for_input(language, dream_text)
233
+ if not (dream_text or "").strip() and not _file_path(image_value) and not _file_path(audio_value):
234
+ session = create_session(language=language)
235
+ session.phase = "error"
236
+ session.events.append(
237
+ TimelineEvent(
238
+ role="error",
239
+ title="还没有梦境材料" if language == "zh" else "No dream material yet",
240
+ body=(
241
+ "请先写一句梦境,或上传图片/语音;这样今日小 Tips 才能引用真实细节。"
242
+ if language == "zh"
243
+ else "Write at least one dream sentence, or add image/voice evidence, so the Today Tip can use real details."
244
+ ),
245
+ status="empty",
246
+ )
247
+ )
248
+ return _view(session, text_backend, vision_backend, language=language, **settings)
249
  text_client, vision_client, asr_client = _clients(text_backend, vision_backend, **settings)
250
  session = add_evidence(
251
  create_session(language=language),
 
271
  language: str = "en",
272
  **settings,
273
  ) -> Tuple[str, str]:
274
+ session = _session_from_state(state)
275
+ language = normalize_language(getattr(session, "language", language))
276
+ session = skip_question(session, language=language)
277
  return _seal_view(session, text_backend, vision_backend, language=language, **settings)
278
 
279
 
 
285
  language: str = "en",
286
  **settings,
287
  ) -> Tuple[str, str]:
288
+ session = _session_from_state(state)
289
+ language = normalize_language(getattr(session, "language", language))
290
+ session = answer_question(session, answer or "", language=language)
291
  if session.phase == "error":
292
  return _view(session, text_backend, vision_backend, language=language, **settings)
293
  return _seal_view(session, text_backend, vision_backend, language=language, **settings)
 
301
  language: str = "en",
302
  **settings,
303
  ) -> Tuple[str, str]:
 
304
  session = _session_from_state(state)
305
+ language = normalize_language(getattr(session, "language", language))
306
  if session.sealed_tip and not session.draft_tip:
307
  session.draft_tip = session.sealed_tip
308
  text_client, _vision_client, _asr_client = _clients(text_backend, vision_backend, **settings)
 
327
  **settings,
328
  ) -> Tuple[str, str]:
329
  text_client, _vision_client, _asr_client = _clients(text_backend, vision_backend, **settings)
330
+ language = normalize_language(getattr(session, "language", language))
331
  session = finish_today_tip(session, text_client, language=language)
332
  return _view(session, text_backend, vision_backend, language=language, **settings)
dream_customs/ui/app.py CHANGED
@@ -88,8 +88,13 @@ VOICE_JS = r"""
88
  button.dataset.bound = "true";
89
 
90
  const messageFor = (key, fallback) => button.dataset[key] || fallback;
 
91
 
92
  const setStatus = (message, mode) => {
 
 
 
 
93
  if (status) {
94
  status.textContent = message;
95
  status.dataset.mode = mode || "";
@@ -119,16 +124,30 @@ VOICE_JS = r"""
119
  }
120
 
121
  setStatus(messageFor("checking", "Checking microphone permission..."), "listening");
 
122
 
123
  const recognition = new Recognition();
124
  recognition.lang = button.dataset.language === "zh" ? "zh-CN" : "en-US";
125
  recognition.interimResults = true;
126
  recognition.continuous = false;
127
  recognition.maxAlternatives = 1;
128
-
129
- let latestTranscript = "";
 
 
 
 
 
 
 
 
 
130
 
131
  recognition.onstart = () => {
 
 
 
 
132
  setStatus(messageFor("listening", "Listening. Say the dream fragment when you are ready."), "listening");
133
  };
134
 
@@ -143,6 +162,10 @@ VOICE_JS = r"""
143
  };
144
 
145
  recognition.onerror = (event) => {
 
 
 
 
146
  const message = event.error === "not-allowed"
147
  ? messageFor("permission", "Microphone permission was denied. Allow recording and try again.")
148
  : messageFor("empty", "I did not catch that. Tap the microphone again if you want to retry.");
@@ -150,6 +173,10 @@ VOICE_JS = r"""
150
  };
151
 
152
  recognition.onend = () => {
 
 
 
 
153
  if (latestTranscript) {
154
  appendTranscript(latestTranscript);
155
  setStatus(messageFor("done", "Added to the dream note."), "done");
@@ -468,6 +495,33 @@ def _agent_dream_qa(dream_text: str, mood: str = "", answer: str = "", language:
468
  """
469
 
470
  language = normalize_language(language)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  state, view_json = submit_dream_action(
472
  dream_text=dream_text,
473
  image_value=None,
@@ -479,6 +533,7 @@ def _agent_dream_qa(dream_text: str, mood: str = "", answer: str = "", language:
479
  )
480
  view = json.loads(view_json)
481
  if view.get("status") != "ask":
 
482
  return view
483
  if answer and answer.strip():
484
  _state, view_json = answer_to_card_action(
@@ -495,7 +550,9 @@ def _agent_dream_qa(dream_text: str, mood: str = "", answer: str = "", language:
495
  vision_backend=DEFAULT_VISION_BACKEND,
496
  language=language,
497
  )
498
- return json.loads(view_json)
 
 
499
 
500
 
501
  def _active_step_for_status(status: str) -> int:
@@ -560,6 +617,7 @@ def _mic_html(language: str = DEFAULT_LANGUAGE) -> str:
560
  aria-label="{escape(copy['mic_idle'])}"
561
  data-language="{escape(language)}"
562
  data-checking="{escape('正在请求麦克风权限...' if language == 'zh' else 'Checking microphone permission...')}"
 
563
  data-unsupported="{escape(copy['mic_unsupported'])}"
564
  data-permission="{escape(copy['mic_permission'])}"
565
  data-listening="{escape(copy['mic_listening'])}"
 
88
  button.dataset.bound = "true";
89
 
90
  const messageFor = (key, fallback) => button.dataset[key] || fallback;
91
+ let checkingTimer = null;
92
 
93
  const setStatus = (message, mode) => {
94
+ if (mode !== "listening" && checkingTimer) {
95
+ window.clearTimeout(checkingTimer);
96
+ checkingTimer = null;
97
+ }
98
  if (status) {
99
  status.textContent = message;
100
  status.dataset.mode = mode || "";
 
124
  }
125
 
126
  setStatus(messageFor("checking", "Checking microphone permission..."), "listening");
127
+ let latestTranscript = "";
128
 
129
  const recognition = new Recognition();
130
  recognition.lang = button.dataset.language === "zh" ? "zh-CN" : "en-US";
131
  recognition.interimResults = true;
132
  recognition.continuous = false;
133
  recognition.maxAlternatives = 1;
134
+ checkingTimer = window.setTimeout(() => {
135
+ if (button.dataset.mode === "listening" && !latestTranscript) {
136
+ setStatus(messageFor("timeout", "Voice is taking too long here. You can keep typing the dream instead."), "error");
137
+ try {
138
+ recognition.stop();
139
+ } catch (_error) {
140
+ // Best-effort stop for browsers that leave speech recognition pending.
141
+ }
142
+ textarea.focus();
143
+ }
144
+ }, 3000);
145
 
146
  recognition.onstart = () => {
147
+ if (checkingTimer) {
148
+ window.clearTimeout(checkingTimer);
149
+ checkingTimer = null;
150
+ }
151
  setStatus(messageFor("listening", "Listening. Say the dream fragment when you are ready."), "listening");
152
  };
153
 
 
162
  };
163
 
164
  recognition.onerror = (event) => {
165
+ if (checkingTimer) {
166
+ window.clearTimeout(checkingTimer);
167
+ checkingTimer = null;
168
+ }
169
  const message = event.error === "not-allowed"
170
  ? messageFor("permission", "Microphone permission was denied. Allow recording and try again.")
171
  : messageFor("empty", "I did not catch that. Tap the microphone again if you want to retry.");
 
173
  };
174
 
175
  recognition.onend = () => {
176
+ if (checkingTimer) {
177
+ window.clearTimeout(checkingTimer);
178
+ checkingTimer = null;
179
+ }
180
  if (latestTranscript) {
181
  appendTranscript(latestTranscript);
182
  setStatus(messageFor("done", "Added to the dream note."), "done");
 
495
  """
496
 
497
  language = normalize_language(language)
498
+ contract = {
499
+ "schema_version": "dream_qa.agent.v1",
500
+ "route_mode": "text_only_queue",
501
+ "api_name": "/agent_dream_qa",
502
+ "expected_fields": ["dream_text", "mood", "answer", "language"],
503
+ "media_note": "Image and voice clues are available in the Gradio UI path; this public agent endpoint is intentionally text-only.",
504
+ }
505
+ if not isinstance(dream_text, str) or not dream_text.strip():
506
+ return {
507
+ "status": "error",
508
+ "phase": "error",
509
+ "language": language,
510
+ "error_code": "missing_dream_text",
511
+ "error": "dream_text is required.",
512
+ "notice": "Provide dream_text as a non-empty string, then call the queue endpoint again.",
513
+ "dream_summary": "",
514
+ "main_question": "",
515
+ "dream_anchors": [],
516
+ "followup_questions": [],
517
+ "user_answers": [],
518
+ "interpretation": "",
519
+ "today_tip": "",
520
+ "tiny_action": "",
521
+ "caring_note": "",
522
+ "safety_note": "",
523
+ "api_contract": contract,
524
+ }
525
  state, view_json = submit_dream_action(
526
  dream_text=dream_text,
527
  image_value=None,
 
533
  )
534
  view = json.loads(view_json)
535
  if view.get("status") != "ask":
536
+ view["api_contract"] = contract
537
  return view
538
  if answer and answer.strip():
539
  _state, view_json = answer_to_card_action(
 
550
  vision_backend=DEFAULT_VISION_BACKEND,
551
  language=language,
552
  )
553
+ result = json.loads(view_json)
554
+ result["api_contract"] = contract
555
+ return result
556
 
557
 
558
  def _active_step_for_status(status: str) -> int:
 
617
  aria-label="{escape(copy['mic_idle'])}"
618
  data-language="{escape(language)}"
619
  data-checking="{escape('正在请求麦克风权限...' if language == 'zh' else 'Checking microphone permission...')}"
620
+ data-timeout="{escape('语音识别等待太久了。你可以先继续手动输入梦境。' if language == 'zh' else 'Voice is taking too long here. You can keep typing the dream instead.')}"
621
  data-unsupported="{escape(copy['mic_unsupported'])}"
622
  data-permission="{escape(copy['mic_permission'])}"
623
  data-listening="{escape(copy['mic_listening'])}"
dream_customs/ui/copy.py CHANGED
@@ -8,7 +8,7 @@ LANGUAGE_OPTIONS = [
8
  APP_COPY = {
9
  "en": {
10
  "title": "Dream QA",
11
- "subtitle": "Record a dream, answer or skip one gentle question, and leave with one grounded Today Tip.",
12
  "brand_subtitle": "Dream Customs",
13
  "steps": ["Record", "Question", "Interpret", "Today Tip"],
14
  "notice_record": "Write a sentence, a few lines, or add image/voice clues. Text-only always works.",
@@ -64,7 +64,7 @@ APP_COPY = {
64
  },
65
  "zh": {
66
  "title": "梦境问答台",
67
- "subtitle": "一步步整理梦境疑惑,回答或跳过温和追问,最后得到一个引用梦境细节的今日小 Tips。",
68
  "brand_subtitle": "Dream Customs",
69
  "steps": ["记录", "追问", "解读", "今日 Tip"],
70
  "notice_record": "写一句、几行,或上传图片/语音。Text-only 路径始终可用。",
 
8
  APP_COPY = {
9
  "en": {
10
  "title": "Dream QA",
11
+ "subtitle": "A Thousand Token Wood-style dream Q&A: record a dream, answer or skip one gentle question, and leave with one grounded Today Tip.",
12
  "brand_subtitle": "Dream Customs",
13
  "steps": ["Record", "Question", "Interpret", "Today Tip"],
14
  "notice_record": "Write a sentence, a few lines, or add image/voice clues. Text-only always works.",
 
64
  },
65
  "zh": {
66
  "title": "梦境问答台",
67
+ "subtitle": "Thousand Token Wood 式梦境问答:一步步整理梦境疑惑,回答或跳过温和追问,最后得到一个引用梦境细节的今日小 Tips。",
68
  "brand_subtitle": "Dream Customs",
69
  "steps": ["记录", "追问", "解读", "今日 Tip"],
70
  "notice_record": "写一句、几行,或上传图片/语音。Text-only 路径始终可用。",
dream_customs/ui/styles.py CHANGED
@@ -970,6 +970,44 @@ a.built-with[href*="gradio.app"] {
970
  font-size: 0.82rem !important;
971
  }
972
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
973
  @keyframes dc-mic-pulse {
974
  0% {
975
  box-shadow: 0 0 0 0 rgba(11, 91, 100, 0.32);
 
970
  font-size: 0.82rem !important;
971
  }
972
 
973
+ .dc-debug-panel,
974
+ .dc-debug-panel .block,
975
+ .dc-debug-panel .wrap,
976
+ .dc-debug-panel .container,
977
+ .dc-debug-panel .cm-editor,
978
+ .dc-debug-panel .cm-scroller,
979
+ .dc-debug-panel .cm-content,
980
+ .dc-dev,
981
+ .dc-dev .block,
982
+ .dc-dev .wrap,
983
+ .dc-dev .container,
984
+ .dc-dev-advanced,
985
+ .dc-dev-advanced .block,
986
+ .dc-dev-advanced .wrap,
987
+ .dc-dev-advanced .container {
988
+ max-width: 100% !important;
989
+ min-width: 0 !important;
990
+ overflow-x: hidden !important;
991
+ }
992
+
993
+ .dc-debug-panel .cm-scroller,
994
+ .dc-debug-panel pre {
995
+ overflow-x: auto !important;
996
+ }
997
+
998
+ .dc-debug-panel .cm-line,
999
+ .dc-debug-panel .cm-content,
1000
+ .dc-debug-panel code,
1001
+ .dc-debug-panel pre {
1002
+ overflow-wrap: anywhere !important;
1003
+ white-space: pre-wrap !important;
1004
+ word-break: break-word !important;
1005
+ }
1006
+
1007
+ .dc-debug-panel .cm-line {
1008
+ max-width: 100% !important;
1009
+ }
1010
+
1011
  @keyframes dc-mic-pulse {
1012
  0% {
1013
  box-shadow: 0 0 0 0 rgba(11, 91, 100, 0.32);
tests/test_agent_readiness.py CHANGED
@@ -1,6 +1,17 @@
1
  from scripts.evaluate_agent_readiness import _agent_api_result, _load_agents_doc
2
 
3
 
 
 
 
 
 
 
 
 
 
 
 
4
  def test_agents_doc_is_present_and_agent_facing():
5
  result = _load_agents_doc("agents.md", None, timeout=1)
6
 
@@ -32,6 +43,45 @@ def test_agent_api_requires_text_only_public_endpoint():
32
  assert result["agent_input_types"] == ["textbox", "textbox", "textbox", "textbox"]
33
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  def test_agent_api_fails_if_media_schema_is_public():
36
  config = {
37
  "components": [
 
1
  from scripts.evaluate_agent_readiness import _agent_api_result, _load_agents_doc
2
 
3
 
4
+ def test_gradio_api_info_alias_matches_agent_endpoint():
5
+ from fastapi.testclient import TestClient
6
+
7
+ from app import demo
8
+
9
+ response = TestClient(demo.app).get("/gradio_api/info")
10
+
11
+ assert response.status_code == 200
12
+ assert "/agent_dream_qa" in response.json()["named_endpoints"]
13
+
14
+
15
  def test_agents_doc_is_present_and_agent_facing():
16
  result = _load_agents_doc("agents.md", None, timeout=1)
17
 
 
43
  assert result["agent_input_types"] == ["textbox", "textbox", "textbox", "textbox"]
44
 
45
 
46
+ def test_agent_dream_qa_returns_stable_flat_contract():
47
+ from dream_customs.ui.app import _agent_dream_qa
48
+
49
+ result = _agent_dream_qa(
50
+ "I dreamed of a red room with no door.",
51
+ mood="Uneasy",
52
+ answer="I woke up nervous.",
53
+ language="en",
54
+ )
55
+
56
+ assert result["api_contract"]["schema_version"] == "dream_qa.agent.v1"
57
+ assert result["api_contract"]["route_mode"] == "text_only_queue"
58
+ assert result["api_contract"]["expected_fields"] == ["dream_text", "mood", "answer", "language"]
59
+ assert "text-only" in result["api_contract"]["media_note"]
60
+ for key in [
61
+ "dream_summary",
62
+ "main_question",
63
+ "dream_anchors",
64
+ "followup_questions",
65
+ "user_answers",
66
+ "interpretation",
67
+ "today_tip",
68
+ "tiny_action",
69
+ "caring_note",
70
+ "safety_note",
71
+ ]:
72
+ assert key in result
73
+
74
+
75
+ def test_agent_dream_qa_rejects_empty_text_with_structured_error():
76
+ from dream_customs.ui.app import _agent_dream_qa
77
+
78
+ result = _agent_dream_qa("", mood="Uneasy", answer="", language="en")
79
+
80
+ assert result["status"] == "error"
81
+ assert result["error_code"] == "missing_dream_text"
82
+ assert result["api_contract"]["expected_fields"] == ["dream_text", "mood", "answer", "language"]
83
+
84
+
85
  def test_agent_api_fails_if_media_schema_is_public():
86
  config = {
87
  "components": [
tests/test_dream_qa_refactor.py CHANGED
@@ -115,8 +115,8 @@ def test_mobile_action_payload_uses_today_tip_not_clearance_pass():
115
  view = json.loads(view_json)
116
 
117
  assert view["status"] == "tip"
118
- assert view["card_title"] == "Today Tip"
119
- assert "elevator" in view["card_text"].lower() or "14" in view["card_text"]
120
  assert "clearance" not in view["card_text"].lower()
121
  assert "permit" not in view["card_html"].lower()
122
  assert "sealed" not in view["card_html"].lower()
 
115
  view = json.loads(view_json)
116
 
117
  assert view["status"] == "tip"
118
+ assert view["card_title"] == "今日小 Tips"
119
+ assert "电梯" in view["card_text"] or "14" in view["card_text"]
120
  assert "clearance" not in view["card_text"].lower()
121
  assert "permit" not in view["card_html"].lower()
122
  assert "sealed" not in view["card_html"].lower()
tests/test_pipeline.py CHANGED
@@ -13,13 +13,14 @@ from dream_customs.pipeline import (
13
  draft_pact,
14
  generate_negotiation,
15
  generate_pact,
 
16
  intake_from_modalities,
17
  revise_pact,
18
  seal_pact,
19
  skip_question,
20
  )
21
  from dream_customs.prompts import pact_prompt
22
- from dream_customs.schema import PactCard, VisionWitness
23
 
24
 
25
  def test_dated_permit_id_uses_runtime_date_and_preserves_serial():
@@ -90,6 +91,144 @@ def test_generate_pact_adds_safety_note_for_distress():
90
  assert card.safety_note
91
 
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  def test_generate_pact_polishes_unclear_model_output_into_daily_tip():
94
  class UnclearTextClient:
95
  def generate_pact(self, prompt):
@@ -354,6 +493,49 @@ def test_add_evidence_prefers_vision_witness_report():
354
  assert "flat clue should not win" not in session.intake.visual_clues
355
 
356
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  def test_witness_failure_keeps_text_path_alive():
358
  class BrokenWitnessVision:
359
  def extract_witness(self, image_path):
 
13
  draft_pact,
14
  generate_negotiation,
15
  generate_pact,
16
+ generate_today_tip,
17
  intake_from_modalities,
18
  revise_pact,
19
  seal_pact,
20
  skip_question,
21
  )
22
  from dream_customs.prompts import pact_prompt
23
+ from dream_customs.schema import PactCard, TodayTipCard, VisionWitness
24
 
25
 
26
  def test_dated_permit_id_uses_runtime_date_and_preserves_serial():
 
91
  assert card.safety_note
92
 
93
 
94
+ def test_generate_today_tip_repairs_placeholder_and_stale_model_anchors():
95
+ class StaleHostedTextClient:
96
+ def generate_today_tip(self, prompt):
97
+ return TodayTipCard(
98
+ dream_summary="I dreamed the elevator button melted and the floor number stayed on 14.",
99
+ main_question="What might that dream detail be asking me to notice today?",
100
+ dream_anchors=["that dream detail"],
101
+ followup_questions=["What did the elevator feel like?"],
102
+ user_answers=[],
103
+ interpretation="Maybe that dream detail points to a stuck point.",
104
+ today_tip="For today, borrow one action from that dream detail and write one line.",
105
+ tiny_action="Spend five minutes with that dream detail.",
106
+ caring_note="Noticing one dream detail is enough.",
107
+ safety_note="",
108
+ )
109
+
110
+ intake = build_intake(
111
+ dream_text="昨晚我梦见自己在一片大雪地里找不到出口,反复回头看到一扇红色的门却打不开。",
112
+ mood="Neutral",
113
+ )
114
+
115
+ card = generate_today_tip(intake, "", StaleHostedTextClient(), language="zh")
116
+ combined = "\n".join(
117
+ [
118
+ card.dream_summary,
119
+ card.main_question,
120
+ ",".join(card.dream_anchors),
121
+ card.interpretation,
122
+ card.today_tip,
123
+ card.tiny_action,
124
+ ]
125
+ )
126
+
127
+ assert "梦里的那个细节" not in combined
128
+ assert "that dream detail" not in combined
129
+ assert "电梯" not in combined
130
+ assert "红色" in combined or "雪地" in combined
131
+ assert card.dream_anchors[0] in {"红色的门", "红色门", "大雪地", "雪地"}
132
+
133
+
134
+ def test_generate_today_tip_keeps_answer_history_and_removes_placeholder_text():
135
+ class PlaceholderTextClient:
136
+ def generate_today_tip(self, prompt):
137
+ return TodayTipCard(
138
+ dream_summary="You dreamed about that dream detail.",
139
+ main_question="What might the that dream detail be asking me to notice today?",
140
+ dream_anchors=["that dream detail"],
141
+ followup_questions=[],
142
+ user_answers=[],
143
+ interpretation="Maybe that dream detail points to a stuck point.",
144
+ today_tip="For today, borrow one action from the that dream detail.",
145
+ tiny_action="Spend five minutes with that dream detail.",
146
+ caring_note="This dream detail does not indicate any real-life concerns.",
147
+ safety_note="",
148
+ )
149
+
150
+ intake = build_intake(
151
+ dream_text="I dreamed I was in a white hallway looking for a classroom while the teacher kept asking for homework.",
152
+ mood="Uneasy",
153
+ )
154
+
155
+ card = generate_today_tip(
156
+ intake,
157
+ "I should message my group lead and send a rough draft tonight.",
158
+ PlaceholderTextClient(),
159
+ language="en",
160
+ )
161
+ combined = "\n".join(
162
+ [
163
+ card.dream_summary,
164
+ card.main_question,
165
+ ",".join(card.dream_anchors),
166
+ "\n".join(card.user_answers),
167
+ card.interpretation,
168
+ card.today_tip,
169
+ card.tiny_action,
170
+ card.caring_note,
171
+ ]
172
+ ).lower()
173
+
174
+ assert "that dream detail" not in combined
175
+ assert "white hallway" in combined or "classroom" in combined or "teacher" in combined
176
+ assert "group lead" in combined
177
+ assert "message" in combined
178
+ assert "does not indicate any real-life concerns" not in combined
179
+
180
+
181
+ def test_generate_today_tip_adds_safety_note_for_repeated_insomnia_without_self_harm():
182
+ intake = build_intake(
183
+ dream_text="我昨晚反复梦见自己在一条漆黑的走廊里走,醒来后心跳很快,已经连续3晚都睡不好。",
184
+ mood="焦虑",
185
+ )
186
+
187
+ card = generate_today_tip(intake, "我想知道今晚能不能睡好一点。", FakeTextClient(), language="zh")
188
+
189
+ assert card.safety_note
190
+ assert "可信任的人" in card.safety_note
191
+
192
+
193
+ def test_generate_today_tip_adds_chinese_safety_note_for_hopeless_text():
194
+ intake = build_intake(dream_text="我梦到站在高楼边缘,醒来后不想醒来,很绝望。")
195
+
196
+ card = generate_today_tip(intake, "", FakeTextClient(), language="zh")
197
+
198
+ assert card.safety_note
199
+ assert "可信任的人" in card.safety_note
200
+
201
+
202
+ def test_chinese_short_and_sensitive_fragments_keep_real_anchors():
203
+ cases = [
204
+ ("昨晚梦见掉进海里。", ["海", "掉进海里"]),
205
+ ("昨晚梦到一个小孩找不到家。", ["小孩", "找不到家"]),
206
+ ("我总是自责,梦里不断重演过去犯的错误。", ["自责", "错误", "重演"]),
207
+ ("梦见有很多追逐场景,醒来心率很快。", ["追逐", "心率"]),
208
+ ]
209
+
210
+ for dream_text, expected_markers in cases:
211
+ card = generate_today_tip(
212
+ build_intake(dream_text=dream_text, mood="焦虑"),
213
+ "用户选择跳过这个追问。",
214
+ FakeTextClient(),
215
+ language="zh",
216
+ )
217
+ combined = "\n".join(
218
+ [
219
+ card.dream_summary,
220
+ ",".join(card.dream_anchors),
221
+ card.interpretation,
222
+ card.today_tip,
223
+ card.tiny_action,
224
+ ]
225
+ )
226
+
227
+ assert any(marker in combined for marker in expected_markers)
228
+ assert "梦里的那个细节" not in combined
229
+ assert "that dream detail" not in combined
230
+
231
+
232
  def test_generate_pact_polishes_unclear_model_output_into_daily_tip():
233
  class UnclearTextClient:
234
  def generate_pact(self, prompt):
 
493
  assert "flat clue should not win" not in session.intake.visual_clues
494
 
495
 
496
+ def test_visual_witness_clues_drive_questions_and_today_tip():
497
+ class RedStairVision:
498
+ def extract_witness(self, image_path):
499
+ return VisionWitness(
500
+ scene_summary="A red staircase inside an old library.",
501
+ objects=["red staircase", "yellow sticky note"],
502
+ visible_text=["CALL HOME"],
503
+ surprising_detail="Rain is drawn outside the window.",
504
+ )
505
+
506
+ def extract_clues(self, image_path):
507
+ return ["flat fallback should not win"]
508
+
509
+ session = add_evidence(
510
+ create_session(),
511
+ dream_text="I only remember a dream fragment.",
512
+ image_path="note.png",
513
+ mood="Uneasy",
514
+ vision_client=RedStairVision(),
515
+ asr_client=FakeASRClient(),
516
+ language="en",
517
+ )
518
+ session = ask_questions(session, FakeTextClient(), language="en")
519
+ session = answer_question(session, "The sticky note felt urgent.", language="en")
520
+ session = skip_question(session, language="en")
521
+ text = "\n".join([session.question_history[0], ",".join(session.qa_state.dream_anchors)]).lower()
522
+
523
+ assert "red staircase" in text or "old library" in text or "yellow sticky note" in text
524
+
525
+ card = generate_today_tip(
526
+ session.intake,
527
+ session.answers_text(),
528
+ FakeTextClient(),
529
+ language="en",
530
+ followup_questions=session.question_history,
531
+ )
532
+ combined = "\n".join([card.dream_summary, ",".join(card.dream_anchors), card.today_tip]).lower()
533
+
534
+ assert "red staircase" in combined or "old library" in combined or "yellow sticky note" in combined
535
+ assert "scene_summary" not in combined
536
+ assert "objects" not in combined
537
+
538
+
539
  def test_witness_failure_keeps_text_path_alive():
540
  class BrokenWitnessVision:
541
  def extract_witness(self, image_path):
tests/test_safety.py CHANGED
@@ -13,5 +13,13 @@ def test_chinese_severe_insomnia_escalates():
13
  assert needs_escalation("我已经很多天睡不着,感觉无法正常生活。")
14
 
15
 
 
 
 
 
16
  def test_safety_note_mentions_professional_support():
17
  assert "professional support" in safety_note().lower()
 
 
 
 
 
13
  assert needs_escalation("我已经很多天睡不着,感觉无法正常生活。")
14
 
15
 
16
+ def test_chinese_hopeless_text_escalates():
17
+ assert needs_escalation("醒来后我不想醒来,很绝望,感觉自己撑不住。")
18
+
19
+
20
  def test_safety_note_mentions_professional_support():
21
  assert "professional support" in safety_note().lower()
22
+
23
+
24
+ def test_chinese_safety_note_mentions_support():
25
+ assert "可信任的人" in safety_note("zh")
tests/test_ui_actions.py CHANGED
@@ -2,7 +2,13 @@ import json
2
  import inspect
3
  from datetime import date
4
 
5
- from dream_customs.ui.actions import answer_to_card_action, initial_mobile_state, skip_to_card_action, submit_dream_action
 
 
 
 
 
 
6
  import dream_customs.ui.app as ui_app
7
  from dream_customs.ui.app import _reset
8
  from dream_customs.ui.copy import DEFAULT_MOOD, PROCESSING_NOTE
@@ -30,6 +36,7 @@ def test_runtime_settings_are_collapsed_for_public_flow():
30
 
31
  def test_voice_input_keeps_modal_asr_component_but_uses_inline_mic_button():
32
  source = inspect.getsource(ui_app.build_demo)
 
33
 
34
  assert "audio_input = gr.Audio(" in source
35
  assert 'sources=["upload"]' in source
@@ -40,6 +47,9 @@ def test_voice_input_keeps_modal_asr_component_but_uses_inline_mic_button():
40
  assert "mic_html = gr.HTML(_mic_html(DEFAULT_LANGUAGE))" in source
41
  assert "audio_input = gr.State(None)" not in source
42
  assert 'value=DEFAULT_ASR_BACKEND' in source
 
 
 
43
 
44
 
45
  def test_image_upload_is_composer_plus_drawer():
@@ -90,6 +100,7 @@ def test_hero_subtitle_explains_app_instead_of_legacy_name():
90
  hero_html = ui_app._hero_html()
91
 
92
  assert "grounded Today Tip" in hero_html
 
93
  assert "Dream Customs" not in hero_html
94
 
95
 
@@ -205,6 +216,57 @@ def test_mobile_mvp_submit_then_skip_generates_today_tip():
205
  assert "Today Tip" in view["card_html"]
206
 
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  def test_mobile_mvp_zh_language_switch_keeps_chinese_today_tip():
209
  state, _view_json = submit_dream_action(
210
  dream_text="我梦到电梯按钮融化,楼层数字停在 14。",
@@ -224,6 +286,8 @@ def test_mobile_mvp_zh_language_switch_keeps_chinese_today_tip():
224
  assert view["language"] == "zh"
225
  assert view["card_title"] == "今日小 Tips"
226
  assert "今日小 Tips" in view["card_html"]
 
 
227
 
228
 
229
  def test_mobile_mvp_answer_to_card_generates_today_tip():
@@ -245,6 +309,11 @@ def test_mobile_mvp_answer_to_card_generates_today_tip():
245
  assert view["status"] == "tip"
246
  assert view["phase"] == "tip"
247
  assert "It may be asking me to slow down." in view["debug"]["session"]["answer_history"]
 
 
 
 
 
248
 
249
 
250
  def test_english_today_tip_has_no_chinese_anchor_leakage():
@@ -300,3 +369,118 @@ def test_english_interpretation_uses_user_answer_before_tip():
300
 
301
  assert "overdue email" in interpretation_line.lower()
302
  assert "floor 14" in interpretation_line.lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import inspect
3
  from datetime import date
4
 
5
+ from dream_customs.ui.actions import (
6
+ answer_to_card_action,
7
+ initial_mobile_state,
8
+ revise_card_action,
9
+ skip_to_card_action,
10
+ submit_dream_action,
11
+ )
12
  import dream_customs.ui.app as ui_app
13
  from dream_customs.ui.app import _reset
14
  from dream_customs.ui.copy import DEFAULT_MOOD, PROCESSING_NOTE
 
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
 
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():
 
100
  hero_html = ui_app._hero_html()
101
 
102
  assert "grounded Today Tip" in hero_html
103
+ assert "Thousand Token Wood" in hero_html
104
  assert "Dream Customs" not in hero_html
105
 
106
 
 
216
  assert "Today Tip" in view["card_html"]
217
 
218
 
219
+ def test_mobile_mvp_empty_submit_stays_on_record_with_clear_error():
220
+ state, view_json = submit_dream_action(
221
+ dream_text="",
222
+ mood="Neutral",
223
+ text_backend="demo",
224
+ vision_backend="demo",
225
+ )
226
+ view = json.loads(view_json)
227
+
228
+ assert view["status"] == "error"
229
+ assert view["phase"] == "error"
230
+ assert "at least one dream sentence" in view["notice"]
231
+ assert "try Continue again" in view["notice"]
232
+ assert view["debug"]["session"]["answer_history"] == []
233
+ assert json.loads(state)["phase"] == "error"
234
+
235
+
236
+ def test_mobile_mvp_auto_switches_chinese_input_to_chinese_result():
237
+ state, view_json = submit_dream_action(
238
+ dream_text="梦里我在一片雾很大的森林里追着一只白色猫跑,周围有很多空白路牌。",
239
+ mood="焦虑",
240
+ text_backend="demo",
241
+ vision_backend="demo",
242
+ language="en",
243
+ )
244
+ view = json.loads(view_json)
245
+
246
+ assert view["language"] == "zh"
247
+ assert json.loads(state)["language"] == "zh"
248
+ assert view["status"] == "ask"
249
+ assert "白色猫" in view["question"] or "森林" in view["question"] or "路牌" in view["question"]
250
+
251
+ _state, view_json = answer_to_card_action(
252
+ state,
253
+ "我其实很在意工作里的选择,梦里看到空白路牌让我很不安。",
254
+ text_backend="demo",
255
+ vision_backend="demo",
256
+ language="en",
257
+ )
258
+ view = json.loads(view_json)
259
+ combined = "\n".join([view["card_text"], view["card_html"]])
260
+
261
+ assert view["language"] == "zh"
262
+ assert view["card_title"] == "今日小 Tips"
263
+ assert "that dream detail" not in combined
264
+ assert "dream detail" not in combined
265
+ assert "追问记录" in view["card_text"]
266
+ assert "用户回答" in view["card_text"]
267
+ assert "MiniCPM5-1B" in view["card_text"]
268
+
269
+
270
  def test_mobile_mvp_zh_language_switch_keeps_chinese_today_tip():
271
  state, _view_json = submit_dream_action(
272
  dream_text="我梦到电梯按钮融化,楼层数字停在 14。",
 
286
  assert view["language"] == "zh"
287
  assert view["card_title"] == "今日小 Tips"
288
  assert "今日小 Tips" in view["card_html"]
289
+ assert "结构化结果 JSON" in view["card_text"]
290
+ assert '"dream_summary"' in view["card_text"]
291
 
292
 
293
  def test_mobile_mvp_answer_to_card_generates_today_tip():
 
309
  assert view["status"] == "tip"
310
  assert view["phase"] == "tip"
311
  assert "It may be asking me to slow down." in view["debug"]["session"]["answer_history"]
312
+ assert "Follow-up questions:" in view["card_text"]
313
+ assert "User answers:" in view["card_text"]
314
+ assert "MiniCPM5-1B" in view["card_text"]
315
+ assert "Structured result JSON:" in view["card_text"]
316
+ assert '"today_tip"' in view["card_text"]
317
 
318
 
319
  def test_english_today_tip_has_no_chinese_anchor_leakage():
 
369
 
370
  assert "overdue email" in interpretation_line.lower()
371
  assert "floor 14" in interpretation_line.lower()
372
+
373
+
374
+ def test_prophecy_framing_is_grounded_before_and_after_tip():
375
+ state, view_json = submit_dream_action(
376
+ dream_text="I dreamed of a black wave and woke thinking: is this a sign something bad will happen?",
377
+ mood="Uneasy",
378
+ text_backend="demo",
379
+ vision_backend="demo",
380
+ language="en",
381
+ )
382
+ view = json.loads(view_json)
383
+
384
+ assert view["status"] == "ask"
385
+ assert "prediction" in view["question"].lower()
386
+
387
+ _state, view_json = answer_to_card_action(
388
+ state,
389
+ "It left a tight feeling in my chest, and I want a grounded answer.",
390
+ text_backend="demo",
391
+ vision_backend="demo",
392
+ language="en",
393
+ )
394
+ view = json.loads(view_json)
395
+ combined = "\n".join([view["card_text"], view["card_html"]]).lower()
396
+
397
+ assert "not as evidence that something bad will happen" in combined
398
+ assert "do not test whether" in combined
399
+ assert "treat it as a prophecy" not in combined
400
+
401
+
402
+ def test_clear_presentation_task_shapes_today_tip():
403
+ state, view_json = submit_dream_action(
404
+ dream_text=(
405
+ "I dreamed of cracked glass before a presentation. "
406
+ "I need to rehearse the first minute tonight."
407
+ ),
408
+ mood="Uneasy",
409
+ text_backend="demo",
410
+ vision_backend="demo",
411
+ language="en",
412
+ )
413
+ view = json.loads(view_json)
414
+
415
+ assert view["status"] == "ask"
416
+ assert "presentation" in view["question"].lower()
417
+
418
+ _state, view_json = answer_to_card_action(
419
+ state,
420
+ "I need to rehearse the first minute tonight.",
421
+ text_backend="demo",
422
+ vision_backend="demo",
423
+ language="en",
424
+ )
425
+ view = json.loads(view_json)
426
+ combined = "\n".join([view["card_text"], view["card_html"]]).lower()
427
+
428
+ assert "presentation" in combined
429
+ assert "first minute" in combined
430
+ assert "cracked glass" in view["dream_anchors"]
431
+ assert "cracked gla" not in view["dream_anchors"]
432
+ assert "reasoning trail" in combined
433
+
434
+
435
+ def test_low_context_skip_stays_low_confidence():
436
+ state, _view_json = submit_dream_action(
437
+ dream_text="red room, no door, woke nervous",
438
+ mood="Uneasy",
439
+ text_backend="demo",
440
+ vision_backend="demo",
441
+ language="en",
442
+ )
443
+
444
+ _state, view_json = skip_to_card_action(
445
+ state,
446
+ text_backend="demo",
447
+ vision_backend="demo",
448
+ language="en",
449
+ )
450
+ view = json.loads(view_json)
451
+ combined = "\n".join([view["card_text"], view["card_html"]]).lower()
452
+
453
+ assert "not enough for a firm reading" in combined
454
+ assert "add one concrete detail" in combined
455
+ assert "elevator" not in combined
456
+
457
+
458
+ def test_revise_card_returns_to_question_state_without_old_card():
459
+ state, _view_json = submit_dream_action(
460
+ dream_text="I dreamed the elevator buttons melted and the elevator never came.",
461
+ mood="Foggy",
462
+ text_backend="demo",
463
+ vision_backend="demo",
464
+ language="en",
465
+ )
466
+ state, _view_json = answer_to_card_action(
467
+ state,
468
+ "It may be asking me to slow down.",
469
+ text_backend="demo",
470
+ vision_backend="demo",
471
+ language="en",
472
+ )
473
+
474
+ _state, view_json = revise_card_action(
475
+ state,
476
+ "Try another angle.",
477
+ text_backend="demo",
478
+ vision_backend="demo",
479
+ language="en",
480
+ )
481
+ view = json.loads(view_json)
482
+
483
+ assert view["status"] == "ask"
484
+ assert view["notice"]
485
+ assert view["question"]
486
+ assert view["card_text"] == ""