fix: harden Dream QA agent path

#29
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
@@ -100,6 +100,17 @@ _ANCHOR_STOPWORDS = {
100
  }
101
 
102
  _ZH_ANCHOR_MARKERS = [
 
 
 
 
 
 
 
 
 
 
 
103
  "电梯按钮",
104
  "融化的按钮",
105
  "按钮融化",
@@ -118,6 +129,17 @@ _ZH_ANCHOR_MARKERS = [
118
  ]
119
 
120
  _ZH_TO_EN_PHRASES = {
 
 
 
 
 
 
 
 
 
 
 
121
  "电梯按钮": "elevator button",
122
  "融化的按钮": "melted button",
123
  "按钮融化": "melted button",
@@ -145,6 +167,26 @@ 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
  [
@@ -167,7 +209,8 @@ 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
  )
@@ -179,7 +222,8 @@ def _extract_dream_anchors(intake: DreamIntake) -> List[str]:
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())
@@ -251,7 +295,9 @@ def _summary_from_intake(intake: DreamIntake, language: str = "en") -> str:
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:
@@ -350,7 +396,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:
@@ -589,7 +644,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 +657,8 @@ 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,11 +668,17 @@ 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:
@@ -634,6 +695,7 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
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)
@@ -641,11 +703,18 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
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:
@@ -657,7 +726,7 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
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
 
100
  }
101
 
102
  _ZH_ANCHOR_MARKERS = [
103
+ "高楼边缘",
104
+ "高楼",
105
+ "边缘",
106
+ "红色的门",
107
+ "红色门",
108
+ "大雪地",
109
+ "雪地",
110
+ "地铁站",
111
+ "地铁",
112
+ "安全帽",
113
+ "男生",
114
  "电梯按钮",
115
  "融化的按钮",
116
  "按钮融化",
 
129
  ]
130
 
131
  _ZH_TO_EN_PHRASES = {
132
+ "高楼边缘": "high-rise edge",
133
+ "高楼": "high-rise",
134
+ "边缘": "edge",
135
+ "红色的门": "red door",
136
+ "红色门": "red door",
137
+ "大雪地": "snowfield",
138
+ "雪地": "snow",
139
+ "地铁站": "subway station",
140
+ "地铁": "subway",
141
+ "安全帽": "hard hat",
142
+ "男生": "young man",
143
  "电梯按钮": "elevator button",
144
  "融化的按钮": "melted button",
145
  "按钮融化": "melted button",
 
167
  return result
168
 
169
 
170
+ _PLACEHOLDER_ANCHORS = {
171
+ "梦里的那个细节",
172
+ "梦里的细节",
173
+ "那个细节",
174
+ "dream detail",
175
+ "that dream detail",
176
+ "that that dream detail",
177
+ "the dream detail",
178
+ }
179
+
180
+
181
+ def _is_placeholder_anchor(text: str) -> bool:
182
+ clean = re.sub(r"\s+", " ", (text or "").strip().lower())
183
+ return not clean or clean in _PLACEHOLDER_ANCHORS or "dream detail" in clean or "梦里的那个细节" in clean
184
+
185
+
186
+ def _remove_placeholder_anchors(items: List[str]) -> List[str]:
187
+ return [item for item in items if not _is_placeholder_anchor(item)]
188
+
189
+
190
  def _extract_dream_anchors(intake: DreamIntake) -> List[str]:
191
  raw_text = " ".join(
192
  [
 
209
  r"paper|papers|promise|promises|window|windows|suitcase|suitcases|"
210
  r"clerk|clerks|sunrise|elevator|elevators|button|buttons|hallway|"
211
  r"gate|gates|floor|floors|stamp|stamps|number|numbers|exam|exams|"
212
+ r"pencil|pencils|train|trains|door|doors|subway|station|stations|snow|snowfield|"
213
+ r"hat|hats|phone|phones|water|shoe|shoes|"
214
  r"stairwell|stairwells|room|rooms|key|keys|note|notes|rain|moon|moons|curtain|curtains"
215
  r")\b"
216
  )
 
222
 
223
  noun_pattern = re.compile(
224
  r"\b(customs|suitcase|clerk|sunrise|elevator|button|hallway|gate|stamp|number|floor|"
225
+ r"exam|pencil|train|door|subway|station|snow|snowfield|hat|phone|water|shoe|stairwell|"
226
+ r"room|key|note|rain|moon|curtain|sleep|dream)\b"
227
  )
228
  candidates.extend(match.group(1) for match in noun_pattern.finditer(text))
229
  candidates.extend(clue.lower() for clue in intake.visual_clues if clue.strip())
 
295
  clean = clean[:69].rstrip() + "..."
296
  if not _is_zh(language):
297
  return clean if clean.lower().startswith(("i ", "you ")) else f"You dreamed about {clean}"
298
+ if clean.startswith(("你", "我", "I ", "i ")) or any(marker in clean[:12] for marker in ("梦见", "梦到")):
299
+ return clean
300
+ return f"你梦见{clean}"
301
 
302
 
303
  def _main_question_from_intake(intake: DreamIntake, language: str = "en") -> str:
 
396
 
397
  def _text_uses_anchor(text: str, anchors: List[str]) -> bool:
398
  clean = (text or "").lower()
399
+ for anchor in anchors:
400
+ item = anchor.lower().strip()
401
+ if not item or _is_placeholder_anchor(item):
402
+ continue
403
+ if item in clean:
404
+ return True
405
+ tokens = [token for token in re.split(r"[\s,,。::;;、]+", item) if len(token) >= 2]
406
+ if any(token in clean for token in tokens):
407
+ return True
408
+ return False
409
 
410
 
411
  def _is_generic_visitor_name(text: str, intake: DreamIntake) -> bool:
 
644
  answers: Optional[List[str]] = None,
645
  language: str = "en",
646
  ) -> DreamQAState:
647
+ anchors = _remove_placeholder_anchors(_anchors_for_language(intake, language))
648
  return DreamQAState(
649
  dream_summary=_summary_from_intake(intake, language),
650
  main_question=_main_question_from_intake(intake, language),
 
657
 
658
  def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = "", language: str = "en") -> TodayTipCard:
659
  polished = card.model_copy(deep=True)
660
+ intake_anchors = _remove_placeholder_anchors(_anchors_for_language(intake, language))
661
+ card_anchors = _remove_placeholder_anchors(
662
  polished.dream_anchors
663
  if _is_zh(language)
664
  else _dedupe_preserve_order([_english_anchor_text(anchor) for anchor in polished.dream_anchors])
 
668
  else:
669
  anchors = card_anchors or intake_anchors
670
  if not anchors:
671
+ anchors = _remove_placeholder_anchors([_primary_anchor(intake, language)])
672
+ if not anchors:
673
+ anchors = ["梦境片段" if _is_zh(language) else "dream fragment"]
674
  polished.dream_anchors = anchors
675
+ if not polished.dream_summary.strip() or not _text_uses_anchor(polished.dream_summary, anchors):
676
  polished.dream_summary = _summary_from_intake(intake, language)
677
+ if (
678
+ not polished.main_question.strip()
679
+ or _is_placeholder_anchor(polished.main_question)
680
+ or not _text_uses_anchor(polished.main_question, anchors)
681
+ ):
682
  polished.main_question = _main_question_from_intake(intake, language)
683
  answer_interpretation = _answer_based_interpretation(answers, _answer_bridge_anchor(anchors), language)
684
  if answer_interpretation:
 
695
  elif (
696
  not polished.today_tip.strip()
697
  or any(marker in polished.today_tip.lower() for marker in generic_tip_markers)
698
+ or _is_placeholder_anchor(polished.today_tip)
699
  or not _anchor_in_text(polished.today_tip, anchors)
700
  ):
701
  polished.today_tip = _grounded_today_tip(intake, language)
 
703
  answer_action = _answer_based_tiny_action(answers, language)
704
  if answer_action and (
705
  not polished.tiny_action.strip()
706
+ or _is_placeholder_anchor(polished.tiny_action)
707
+ or not _anchor_in_text(polished.tiny_action, anchors)
708
  or any(marker in polished.tiny_action.lower() for marker in hard_action_markers)
709
  or "email" in (answers or "").lower()
710
  ):
711
  polished.tiny_action = answer_action
712
+ elif (
713
+ not polished.tiny_action.strip()
714
+ or _is_placeholder_anchor(polished.tiny_action)
715
+ or not _anchor_in_text(polished.tiny_action, anchors)
716
+ or any(marker in polished.tiny_action.lower() for marker in hard_action_markers)
717
+ ):
718
  if _is_zh(language):
719
  polished.tiny_action = f"用 5 分钟写下:今天和「{anchors[0]}」有关的第一小步是什么?"
720
  else:
 
726
  else "You do not have to solve the whole dream this morning; noticing one detail is enough."
727
  )
728
  merged = "\n".join([intake.merged_text(), answers or ""])
729
+ polished.safety_note = safety_note(language) if needs_escalation(merged) else ""
730
  if not _is_zh(language):
731
  polished = _clean_english_today_tip_language(polished)
732
  return polished
dream_customs/safety.py CHANGED
@@ -1,6 +1,12 @@
1
  ESCALATION_TERMS = (
2
  "hurt myself",
3
  "kill myself",
 
 
 
 
 
 
4
  "suicide",
5
  "self-harm",
6
  "hurt someone",
@@ -10,6 +16,13 @@ ESCALATION_TERMS = (
10
  "想伤害自己",
11
  "自杀",
12
  "自残",
 
 
 
 
 
 
 
13
  "伤害别人",
14
  "很多天睡不着",
15
  "无法正常生活",
@@ -22,7 +35,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",
 
16
  "想伤害自己",
17
  "自杀",
18
  "自残",
19
+ "不想醒来",
20
+ "不想活",
21
+ "活不下去",
22
+ "轻生",
23
+ "绝望",
24
+ "撑不住",
25
+ "崩溃",
26
  "伤害别人",
27
  "很多天睡不着",
28
  "无法正常生活",
 
35
  return any(term in lowered for term in ESCALATION_TERMS)
36
 
37
 
38
+ def safety_note(language: str = "en") -> str:
39
+ if language == "zh":
40
+ return (
41
+ "这段梦和醒来后的感受听起来已经超过了一个轻量梦境反思工具适合独自处理的范围。"
42
+ "如果你感到不安全、连续很多天睡不着,或担心自己可能伤害自己或别人,请现在联系一个可信任的人,"
43
+ "或寻求专业支持。"
44
+ )
45
  return (
46
  "This dream sounds heavier than a playful reflection tool should handle. "
47
  "If you feel unsafe, cannot sleep for many nights, or worry you may hurt "
dream_customs/ui/actions.py CHANGED
@@ -83,7 +83,7 @@ def _view_payload(
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,
@@ -96,6 +96,22 @@ def _view_payload(
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(
 
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
+ payload = {
87
  "status": status,
88
  "phase": session.phase,
89
  "language": language,
 
96
  "notice": _notice_for_status(status, error, language),
97
  "debug": json.loads(_debug_json(session, text_backend, vision_backend, **settings)),
98
  }
99
+ if card:
100
+ payload.update(
101
+ {
102
+ "dream_summary": card.dream_summary,
103
+ "main_question": card.main_question,
104
+ "dream_anchors": card.dream_anchors,
105
+ "followup_questions": card.followup_questions,
106
+ "user_answers": card.user_answers,
107
+ "interpretation": card.interpretation,
108
+ "today_tip": card.today_tip,
109
+ "tiny_action": card.tiny_action,
110
+ "caring_note": card.caring_note,
111
+ "safety_note": card.safety_note,
112
+ }
113
+ )
114
+ return payload
115
 
116
 
117
  def _view(
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
 
 
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
 
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,55 @@ 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):
 
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_adds_chinese_safety_note_for_hopeless_text():
135
+ intake = build_intake(dream_text="我梦到站在高楼边缘,醒来后不想醒来,很绝望。")
136
+
137
+ card = generate_today_tip(intake, "", FakeTextClient(), language="zh")
138
+
139
+ assert card.safety_note
140
+ assert "可信任的人" in card.safety_note
141
+
142
+
143
  def test_generate_pact_polishes_unclear_model_output_into_daily_tip():
144
  class UnclearTextClient:
145
  def generate_pact(self, prompt):
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")