Finalize Dream QA agent hardening
#30
by ADJCJH - opened
- app.py +48 -0
- dream_customs/pipeline.py +587 -30
- dream_customs/safety.py +28 -1
- dream_customs/schema.py +1 -0
- dream_customs/ui/actions.py +118 -13
- dream_customs/ui/app.py +61 -3
- dream_customs/ui/copy.py +2 -2
- dream_customs/ui/styles.py +38 -0
- tests/test_agent_readiness.py +50 -0
- tests/test_dream_qa_refactor.py +2 -2
- tests/test_pipeline.py +183 -1
- tests/test_safety.py +8 -0
- tests/test_ui_actions.py +185 -1
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|
|
| 171 |
-
r"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
r")\b"
|
| 173 |
)
|
| 174 |
for match in pair_pattern.finditer(text):
|
| 175 |
modifier, noun = match.groups()
|
| 176 |
-
|
|
|
|
| 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|
|
|
|
|
|
|
|
| 183 |
)
|
| 184 |
candidates.extend(match.group(1) for match in noun_pattern.finditer(text))
|
| 185 |
-
candidates.extend(
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 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
|
| 272 |
-
f"beside
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 606 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 619 |
polished.dream_summary = _summary_from_intake(intake, language)
|
| 620 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 667 |
language = _normalize_language(language)
|
| 668 |
-
qa_state = build_qa_state(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 83 |
-
|
|
|
|
| 84 |
error = _latest_error(session)
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 173 |
-
|
|
|
|
| 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 |
-
|
| 186 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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": "
|
| 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"] == "
|
| 119 |
-
assert "
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"] == ""
|