Add Dream QA agent readiness API

#28
by ADJCJH - opened
AGENTS.md DELETED
@@ -1,119 +0,0 @@
1
- # AGENTS.md - Dream QA / 梦境问答台
2
-
3
- ## 语言规则
4
-
5
- - 默认使用中文回复用户。
6
- - 面向评审/README/Space 展示的文字可以中英双语,但不要牺牲中文表达的清晰度。
7
-
8
- ## 项目定位
9
-
10
- - 当前项目是 Build Small Hackathon 的 `Dream QA / 梦境问答台`,代码仓库和 Space 仍沿用 `Dream Customs` 名称以保持部署连续性。
11
- - 核心定位:做一个循序渐进的解梦 Gradio app,帮助用户一步步说清梦境、表达疑惑、回答追问,最后得到一个温和的 `今日小 Tips`。
12
- - `今日小 Tips` 可以是一个认真建议、一个用户未尝试过的小事,或一句关心用户的短句;它必须引用梦里的具体细节,不能变成泛泛鸡汤。
13
- - 这不是心理诊断、医疗建议、治疗工具、占卜工具或宿命论解释器。
14
-
15
- ## 竞赛约束
16
-
17
- - 目标交付:Gradio app on Hugging Face Space。
18
- - 模型总参数必须 <= 32B。
19
- - 优先叙事赛道:An Adventure in Thousand Token Wood。
20
- - 次级叙事:Backyard AI,因为项目来自真实睡眠/做梦困扰。
21
- - demo 必须能在短视频中快速展示:输入梦境,回答或跳过追问,获得今日小 Tips。
22
-
23
- ## 模型规则
24
-
25
- - 首选模型组合:
26
- - `openbmb/MiniCPM-V-4.6`:图片、草图、便签、截图理解。
27
- - `openbmb/MiniCPM5-1B`:文本推理、追问生成、梦境解读草稿、结构化今日 Tips。
28
- - 不要在 MiniCPM 路线未验证前扩展到任意其他小模型。
29
- - 语音输入需要 ASR 适配器;ASR 只做转写,不承担梦境理解。
30
- - 首版不要实现语音输出,避免扩大部署风险。
31
-
32
- ## 产品边界
33
-
34
- - 必须支持文字输入。
35
- - 应支持图片输入,用 MiniCPM-V-4.6 提取视觉线索。
36
- - 应支持语音输入,通过小 ASR 适配器转成文本。
37
- - 所有输入统一进入同一个梦境 intake,不要做成三套割裂产品。
38
- - 用户流程必须是渐进式的:
39
- 1. 记录梦境。
40
- 2. 明确用户最想理解的问题。
41
- 3. 生成 1-3 个温和追问。
42
- 4. 用户回答、跳过或请求换个角度。
43
- 5. 生成梦境解读草稿。
44
- 6. 输出一个今日小 Tips。
45
- - 输出必须包含:
46
- - `dream_summary` / 梦境摘要
47
- - `main_question` / 用户最想理解的问题
48
- - `dream_anchors` / 梦境具体线索
49
- - `followup_questions` / 追问
50
- - `user_answers` / 用户回答或跳过记录
51
- - `interpretation` / 非确定性的解读草稿
52
- - `today_tip` / 今日小 Tips
53
- - `tiny_action` / 可选的小行动
54
- - `caring_note` / 可选的关心语句
55
- - `safety_note` / 必要时的安全提示
56
-
57
- ## 安全规则
58
-
59
- - 不做心理疾病诊断。
60
- - 不声称梦境有唯一含义。
61
- - 不输出恐吓性、宿命论、神秘绝对化内容。
62
- - 不把梦解释成“预兆”“证明”“创伤证据”或医学结论。
63
- - 普通输出使用“也许”“可以把它当作”“今天先试试”这类非确定措辞。
64
- - 如果用户表达自伤、伤人、长期严重失眠、无法正常生活或极强痛苦,必须显示寻求可信任的人或专业支持的提示。
65
-
66
- ## 文档优先级
67
-
68
- 实现前先阅读:
69
-
70
- 1. `docs/handoff.md`
71
- 2. `docs/spec.md`
72
- 3. `docs/prd.md`
73
- 4. `PRODUCT.md`
74
- 5. `DESIGN.md`
75
- 6. `docs/superpowers/plans/2026-06-08-dream-qa-refactor.md`
76
- 7. `docs/superpowers/plans/2026-06-08-dream-qa-refactor-doc-plan.md`
77
-
78
- 旧的 pact/customs 计划、smoke 和概念页是历史记录。除非用户明确要求复盘历史,否则不要再把 permit、contraband、sealed pact 当作新实现目标。
79
-
80
- ## 开发流程
81
-
82
- - 修改或实验前先确认工作目录是 `/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon`。
83
- - 当前目录可能位于父级 `vlarepo` git root 下;不要误推到 VLA remote。
84
- - 如果需要正式提交,先确认是否已初始化/切换到 Dream Customs 专用仓库。
85
- - 在仓库归属未确认前,可以本地写文件,但不要强行 `git add` / `git commit` / `git push` 到父级 VLA 仓库。
86
- - 如果建立新功能分支,使用 `feature/xxx` 命名。
87
- - 用户要求修改完成后,默认不仅推送 GitHub 分支,也要同步提交到 Hugging Face Space,并完成可用的 merge / 部署路径;不要只停在本地或 GitHub 分支。
88
- - 同步 HF Space 前先确认 `space` remote 指向 `build-small-hackathon/dream-customs`,并复核当前分支与提交 SHA,避免误推到其他 Space 或父级仓库。
89
- - 如果 HF Space 同步涉及密钥、账单、权限弹窗、强制覆盖公开 `main`、或无法自动 merge 的 PR/Discussion,则先说明具体阻塞并请求用户确认;不要打印或保存任何 token。
90
-
91
- ## 实现原则
92
-
93
- - 先用 fake clients 打通 schema、safety、render、pipeline 和 Gradio UI。
94
- - 核心测试通过后再接真实 MiniCPM 模型。
95
- - 所有模型输出都必须经过 schema 校验。
96
- - JSON 解析失败时必须有修复或降级路径。
97
- - Text-only path 必须永远可用,作为 demo fallback。
98
- - Gradio 页面应优先移动端可用,因为用户可能在��机上记录梦。
99
- - 今日 Tips 必须有梦境锚点,不能是“多休息、多喝水、保持积极”这类脱离梦境的通用话。
100
-
101
- ## 验收命令
102
-
103
- 基础验收:
104
-
105
- ```bash
106
- python -m pytest -q
107
- python app.py
108
- ```
109
-
110
- 人工验收:
111
-
112
- - 输入一段文字梦境后,app 能提出至少一个相关追问。
113
- - 用户可以回答追问,也可以跳过。
114
- - 上传一张草图或便签后,视觉线索进入同一个 intake。
115
- - 上传/录制语音后,有转写或清晰降级提示。
116
- - 最终输出包含梦境摘要、解读草稿和一个今日小 Tips。
117
- - 今日小 Tips 引用至少一个具体梦境细节。
118
- - 输出不包含诊断、恐吓、预言或医疗化建议。
119
- - 页面在手机宽度下可读。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
agents.md ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dream QA Agent Guide
2
+
3
+ This file is the agent-facing entry point for the Dream QA / Dream Customs Space.
4
+
5
+ ```bash
6
+ curl https://huggingface.co/spaces/build-small-hackathon/dream-customs/agents.md
7
+ ```
8
+
9
+ ## App
10
+
11
+ - Public Space: https://huggingface.co/spaces/build-small-hackathon/dream-customs
12
+ - Runtime URL: https://build-small-hackathon-dream-customs.hf.space
13
+ - Product name: Dream QA
14
+ - Task: turn a dream fragment into one grounded follow-up question and one gentle Today Tip.
15
+
16
+ Dream QA is not a diagnosis, therapy, prophecy, or emergency tool. Treat every result as a soft reflection.
17
+
18
+ ## Recommended Agent API
19
+
20
+ Use the text-only Gradio API endpoint:
21
+
22
+ ```python
23
+ from gradio_client import Client
24
+
25
+ client = Client("https://build-small-hackathon-dream-customs.hf.space")
26
+ result = client.predict(
27
+ "I dreamed the elevator button melted and the number stayed on 14.",
28
+ "Uneasy",
29
+ "It felt like being late before I had even started.",
30
+ "en",
31
+ api_name="/agent_dream_qa",
32
+ )
33
+ print(result["card_text"])
34
+ ```
35
+
36
+ Input order:
37
+
38
+ 1. `dream_text`: required dream fragment.
39
+ 2. `mood`: optional waking mood, for example `Neutral`, `Uneasy`, `Foggy`, `Curious`, `Tired`, `A little amused`.
40
+ 3. `answer`: optional answer to the follow-up question. Leave it empty to let Dream QA skip and generate a tip from the dream clues.
41
+ 4. `language`: `en` or `zh`.
42
+
43
+ Expected output is a JSON object with:
44
+
45
+ - `status`: usually `tip` after a complete API call.
46
+ - `question`: the grounded follow-up question generated from the dream.
47
+ - `card_title`: `Today Tip` or `今日小 Tips`.
48
+ - `card_text`: copyable result text.
49
+ - `card_html`: rendered result card.
50
+ - `debug`: backend route and session state without secrets.
51
+
52
+ ## Evaluation Checklist
53
+
54
+ An agent should consider the app healthy when all of these pass:
55
+
56
+ - `curl https://huggingface.co/spaces/build-small-hackathon/dream-customs/agents.md` returns this guide, not `Entry not found`.
57
+ - `/config` title is `Dream QA`.
58
+ - Text generation, image understanding, and voice input default to `modal`.
59
+ - `/config` exposes `agent_dream_qa` and does not require image/audio inputs for the public agent API.
60
+ - A sample dream reaches `status == "tip"`.
61
+ - The Today Tip mentions at least one concrete dream anchor.
62
+ - The result avoids diagnosis, prophecy, fate claims, and frightening certainty.
63
+
64
+ ## Good Smoke Prompt
65
+
66
+ ```text
67
+ Dream: I dreamed I was in an old apartment building. The elevator button melted like wax, and the floor number stayed on 14.
68
+ Mood: Uneasy
69
+ Answer: It felt like being late before I had even started.
70
+ ```
71
+
72
+ The result should mention the elevator, button, or 14, and should suggest one small first step rather than a hard command.
dream_customs/ui/app.py CHANGED
@@ -1,4 +1,5 @@
1
  import json
 
2
  from html import escape
3
 
4
  import gradio as gr
@@ -58,6 +59,22 @@ def _json_schema_to_python_type(schema, defs):
58
 
59
  gradio_client_utils._json_schema_to_python_type = _json_schema_to_python_type
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  VOICE_JS = r"""
62
  () => {
63
  const bindVoiceButton = () => {
@@ -441,6 +458,46 @@ def _reset(language, text_backend, vision_backend, *settings_values):
441
  return (*_updates(state, view_json), "", "", None, None, default_mood_for(language))
442
 
443
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
  def _active_step_for_status(status: str) -> int:
445
  return {"record": 1, "error": 1, "ask": 2, "drafting": 3, "tip": 4}.get(status, 1)
446
 
@@ -605,6 +662,7 @@ def build_demo() -> gr.Blocks:
605
  height=180,
606
  elem_classes=["dc-image-popover"],
607
  )
 
608
  audio_input = gr.Audio(
609
  label=initial_copy["voice_label"],
610
  sources=["upload"],
@@ -613,6 +671,7 @@ def build_demo() -> gr.Blocks:
613
  elem_classes=["dc-voice-input"],
614
  visible=False,
615
  )
 
616
  field_tip_html = gr.HTML(_field_tip_html(DEFAULT_LANGUAGE))
617
  with gr.Row(elem_classes=["dc-submit-row"]):
618
  example_button = gr.Button(initial_copy["example_button"], variant="secondary")
@@ -753,6 +812,14 @@ def build_demo() -> gr.Blocks:
753
  precision=0,
754
  )
755
 
 
 
 
 
 
 
 
 
756
  outputs = [
757
  session_state,
758
  view_state,
@@ -791,39 +858,51 @@ def build_demo() -> gr.Blocks:
791
  _submit,
792
  inputs=[dream_text, image_input, audio_input, mood, language, text_backend, vision_backend] + settings_inputs,
793
  outputs=outputs,
 
 
794
  )
795
  answer_button.click(
796
  _answer,
797
  inputs=[session_state, answer_text, language, text_backend, vision_backend] + settings_inputs,
798
  outputs=outputs,
 
 
799
  )
800
  skip_button.click(
801
  _skip,
802
  inputs=[session_state, language, text_backend, vision_backend] + settings_inputs,
803
  outputs=outputs,
 
 
804
  )
805
  gentle_button.click(
806
  _revise,
807
  inputs=[session_state, gr.State("softer"), language, text_backend, vision_backend] + settings_inputs,
808
  outputs=outputs,
 
 
809
  )
810
  weird_button.click(
811
  _revise,
812
  inputs=[session_state, gr.State("stranger"), language, text_backend, vision_backend] + settings_inputs,
813
  outputs=outputs,
 
 
814
  )
815
- copy_button.click(lambda text: text, inputs=card_text, outputs=card_text)
816
  reset_button.click(
817
  _reset,
818
  inputs=[language, text_backend, vision_backend] + settings_inputs,
819
  outputs=outputs + [dream_text, answer_text, image_input, audio_input, mood],
 
 
820
  )
821
 
822
  def _example(selected_language):
823
  selected_language = normalize_language(selected_language)
824
  return EXAMPLE_DREAMS[selected_language], EXAMPLE_MOODS[selected_language]
825
 
826
- example_button.click(_example, inputs=language, outputs=[dream_text, mood])
827
 
828
  def _language_ui(selected_language):
829
  selected_language = normalize_language(selected_language)
@@ -893,6 +972,15 @@ def build_demo() -> gr.Blocks:
893
  debug_help_html,
894
  debug_json,
895
  ],
 
 
 
 
 
 
 
 
 
896
  )
897
 
898
  return demo
 
1
  import json
2
+ from copy import deepcopy
3
  from html import escape
4
 
5
  import gradio as gr
 
59
 
60
  gradio_client_utils._json_schema_to_python_type = _json_schema_to_python_type
61
 
62
+
63
+ def _make_media_api_info_client_safe(component):
64
+ """Patch Gradio FileData schemas so gradio_client can parse the config."""
65
+
66
+ original_api_info = component.api_info
67
+
68
+ def api_info():
69
+ schema = deepcopy(original_api_info())
70
+ meta = schema.get("properties", {}).get("meta", {})
71
+ if isinstance(meta.get("additionalProperties"), bool):
72
+ meta["additionalProperties"] = {"type": "string"}
73
+ return schema
74
+
75
+ component.api_info = api_info
76
+
77
+
78
  VOICE_JS = r"""
79
  () => {
80
  const bindVoiceButton = () => {
 
458
  return (*_updates(state, view_json), "", "", None, None, default_mood_for(language))
459
 
460
 
461
+ def _agent_dream_qa(dream_text: str, mood: str = "", answer: str = "", language: str = DEFAULT_LANGUAGE) -> dict:
462
+ """Text-only public API for external agents.
463
+
464
+ The visual UI keeps image and voice inputs, but those components currently
465
+ generate Gradio schemas that some agent clients cannot parse. This endpoint
466
+ keeps the agent path simple and stable while still exercising the same
467
+ Dream QA pipeline and configured Modal text backend.
468
+ """
469
+
470
+ language = normalize_language(language)
471
+ state, view_json = submit_dream_action(
472
+ dream_text=dream_text,
473
+ image_value=None,
474
+ audio_value=None,
475
+ mood=mood,
476
+ text_backend=DEFAULT_TEXT_BACKEND,
477
+ vision_backend=DEFAULT_VISION_BACKEND,
478
+ language=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(
485
+ state,
486
+ answer=answer,
487
+ text_backend=DEFAULT_TEXT_BACKEND,
488
+ vision_backend=DEFAULT_VISION_BACKEND,
489
+ language=language,
490
+ )
491
+ else:
492
+ _state, view_json = skip_to_card_action(
493
+ state,
494
+ text_backend=DEFAULT_TEXT_BACKEND,
495
+ vision_backend=DEFAULT_VISION_BACKEND,
496
+ language=language,
497
+ )
498
+ return json.loads(view_json)
499
+
500
+
501
  def _active_step_for_status(status: str) -> int:
502
  return {"record": 1, "error": 1, "ask": 2, "drafting": 3, "tip": 4}.get(status, 1)
503
 
 
662
  height=180,
663
  elem_classes=["dc-image-popover"],
664
  )
665
+ _make_media_api_info_client_safe(image_input)
666
  audio_input = gr.Audio(
667
  label=initial_copy["voice_label"],
668
  sources=["upload"],
 
671
  elem_classes=["dc-voice-input"],
672
  visible=False,
673
  )
674
+ _make_media_api_info_client_safe(audio_input)
675
  field_tip_html = gr.HTML(_field_tip_html(DEFAULT_LANGUAGE))
676
  with gr.Row(elem_classes=["dc-submit-row"]):
677
  example_button = gr.Button(initial_copy["example_button"], variant="secondary")
 
812
  precision=0,
813
  )
814
 
815
+ with gr.Group(visible=False):
816
+ agent_dream_text = gr.Textbox(label="Agent dream text")
817
+ agent_mood = gr.Textbox(label="Agent mood", value=DEFAULT_MOOD)
818
+ agent_answer = gr.Textbox(label="Agent answer")
819
+ agent_language = gr.Textbox(label="Agent language", value=DEFAULT_LANGUAGE)
820
+ agent_result = gr.JSON(label="Agent Dream QA result")
821
+ agent_button = gr.Button("Agent Dream QA")
822
+
823
  outputs = [
824
  session_state,
825
  view_state,
 
858
  _submit,
859
  inputs=[dream_text, image_input, audio_input, mood, language, text_backend, vision_backend] + settings_inputs,
860
  outputs=outputs,
861
+ api_name=False,
862
+ show_api=False,
863
  )
864
  answer_button.click(
865
  _answer,
866
  inputs=[session_state, answer_text, language, text_backend, vision_backend] + settings_inputs,
867
  outputs=outputs,
868
+ api_name=False,
869
+ show_api=False,
870
  )
871
  skip_button.click(
872
  _skip,
873
  inputs=[session_state, language, text_backend, vision_backend] + settings_inputs,
874
  outputs=outputs,
875
+ api_name=False,
876
+ show_api=False,
877
  )
878
  gentle_button.click(
879
  _revise,
880
  inputs=[session_state, gr.State("softer"), language, text_backend, vision_backend] + settings_inputs,
881
  outputs=outputs,
882
+ api_name=False,
883
+ show_api=False,
884
  )
885
  weird_button.click(
886
  _revise,
887
  inputs=[session_state, gr.State("stranger"), language, text_backend, vision_backend] + settings_inputs,
888
  outputs=outputs,
889
+ api_name=False,
890
+ show_api=False,
891
  )
892
+ copy_button.click(lambda text: text, inputs=card_text, outputs=card_text, api_name=False, show_api=False)
893
  reset_button.click(
894
  _reset,
895
  inputs=[language, text_backend, vision_backend] + settings_inputs,
896
  outputs=outputs + [dream_text, answer_text, image_input, audio_input, mood],
897
+ api_name=False,
898
+ show_api=False,
899
  )
900
 
901
  def _example(selected_language):
902
  selected_language = normalize_language(selected_language)
903
  return EXAMPLE_DREAMS[selected_language], EXAMPLE_MOODS[selected_language]
904
 
905
+ example_button.click(_example, inputs=language, outputs=[dream_text, mood], api_name=False, show_api=False)
906
 
907
  def _language_ui(selected_language):
908
  selected_language = normalize_language(selected_language)
 
972
  debug_help_html,
973
  debug_json,
974
  ],
975
+ api_name=False,
976
+ show_api=False,
977
+ )
978
+ agent_button.click(
979
+ _agent_dream_qa,
980
+ inputs=[agent_dream_text, agent_mood, agent_answer, agent_language],
981
+ outputs=agent_result,
982
+ api_name="agent_dream_qa",
983
+ show_api=True,
984
  )
985
 
986
  return demo
scripts/evaluate_agent_readiness.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Evaluate whether Dream QA is ready for external agents."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+ from urllib.error import URLError
13
+ from urllib.request import urlopen
14
+
15
+ ROOT = Path(__file__).resolve().parents[1]
16
+ if str(ROOT) not in sys.path:
17
+ sys.path.insert(0, str(ROOT))
18
+
19
+ from scripts.evaluate_today_tip_quality import evaluate_cases, _load_cases
20
+ from scripts.smoke_local_space_mirror import inspect_config
21
+
22
+
23
+ DEFAULT_APP_URL = "http://127.0.0.1:7862"
24
+ DEFAULT_AGENTS_URL = "https://huggingface.co/spaces/build-small-hackathon/dream-customs/agents.md"
25
+ REQUIRED_AGENT_DOC_TERMS = [
26
+ "Dream QA",
27
+ "agent_dream_qa",
28
+ "gradio_client",
29
+ "Today Tip",
30
+ "not a diagnosis",
31
+ ]
32
+
33
+
34
+ def _read_url(url: str, timeout: float) -> str:
35
+ with urlopen(url, timeout=timeout) as response:
36
+ return response.read().decode("utf-8", errors="replace")
37
+
38
+
39
+ def _read_local_agents_doc(path: str) -> str:
40
+ text = Path(path).read_text(encoding="utf-8")
41
+ if Path(path).name == "agents.md" and "Dream QA Agent Guide" not in text:
42
+ staged = subprocess.run(
43
+ ["git", "show", ":agents.md"],
44
+ check=False,
45
+ capture_output=True,
46
+ text=True,
47
+ )
48
+ if staged.returncode == 0 and "Dream QA Agent Guide" in staged.stdout:
49
+ return staged.stdout
50
+ return text
51
+
52
+
53
+ def _load_agents_doc(path: str | None, url: str | None, timeout: float) -> dict[str, Any]:
54
+ source = path or url
55
+ try:
56
+ text = _read_local_agents_doc(path) if path else _read_url(str(url), timeout)
57
+ except (OSError, URLError, TimeoutError) as exc:
58
+ return {"source": source, "passes": False, "issues": [f"unreadable:{type(exc).__name__}"]}
59
+
60
+ lowered = text.lower()
61
+ issues = []
62
+ if lowered.strip() == "entry not found":
63
+ issues.append("entry_not_found")
64
+ for term in REQUIRED_AGENT_DOC_TERMS:
65
+ if term.lower() not in lowered:
66
+ issues.append(f"missing:{term}")
67
+ return {
68
+ "source": source,
69
+ "passes": not issues,
70
+ "issues": issues,
71
+ "bytes": len(text.encode("utf-8")),
72
+ }
73
+
74
+
75
+ def _fetch_config(app_url: str, timeout: float) -> dict[str, Any]:
76
+ return json.loads(_read_url(f"{app_url.rstrip('/')}/config", timeout))
77
+
78
+
79
+ def _agent_api_result(config: dict[str, Any]) -> dict[str, Any]:
80
+ dependencies = config.get("dependencies", [])
81
+ public_api_names = [item.get("api_name") for item in dependencies if item.get("api_name")]
82
+ agent_deps = [item for item in dependencies if item.get("api_name") == "agent_dream_qa"]
83
+ issues = []
84
+ if not agent_deps:
85
+ issues.append("missing_agent_dream_qa")
86
+ if any(name in {"_submit", "_answer", "_skip"} for name in public_api_names):
87
+ issues.append("ui_events_exposed_as_public_api")
88
+
89
+ components_by_id = {component.get("id"): component for component in config.get("components", [])}
90
+ if agent_deps:
91
+ input_types = [
92
+ components_by_id.get(component_id, {}).get("type")
93
+ for component_id in agent_deps[0].get("inputs", [])
94
+ ]
95
+ if any(kind in {"image", "audio"} for kind in input_types):
96
+ issues.append("agent_api_requires_media_schema")
97
+ else:
98
+ input_types = []
99
+
100
+ return {
101
+ "passes": not issues,
102
+ "issues": issues,
103
+ "public_api_names": public_api_names,
104
+ "agent_input_types": input_types,
105
+ }
106
+
107
+
108
+ def _gradio_client_result(app_url: str, timeout: float) -> dict[str, Any]:
109
+ try:
110
+ from gradio_client import Client
111
+
112
+ client = Client(app_url)
113
+ result = client.predict(
114
+ "I dreamed I was in an old apartment building. The elevator button melted like wax, and the floor number stayed on 14.",
115
+ "Uneasy",
116
+ "It felt like being late before I had even started.",
117
+ "en",
118
+ api_name="/agent_dream_qa",
119
+ )
120
+ except Exception as exc: # pragma: no cover - exercised by smoke command
121
+ return {"passes": False, "issues": [f"{type(exc).__name__}:{str(exc).splitlines()[0]}"]}
122
+
123
+ text = json.dumps(result, ensure_ascii=False).lower()
124
+ issues = []
125
+ if result.get("status") != "tip":
126
+ issues.append(f"status:{result.get('status')}")
127
+ if not any(anchor in text for anchor in ["elevator", "button", "14"]):
128
+ issues.append("missing_anchor")
129
+ unsafe_terms = [
130
+ "you have depression",
131
+ "this dream proves",
132
+ "this means you will",
133
+ "prophecy says",
134
+ "fate says",
135
+ "you will fail",
136
+ ]
137
+ if any(term in text for term in unsafe_terms):
138
+ issues.append("unsafe_language")
139
+ return {"passes": not issues, "issues": issues, "status": result.get("status")}
140
+
141
+
142
+ def evaluate(app_url: str, agents_path: str | None, agents_url: str | None, run_client: bool, timeout: float) -> dict[str, Any]:
143
+ config = _fetch_config(app_url, timeout)
144
+ config_result = inspect_config(config)
145
+ report = {
146
+ "app_url": app_url,
147
+ "agents_doc": _load_agents_doc(agents_path, agents_url, timeout),
148
+ "space_config": config_result,
149
+ "agent_api": _agent_api_result(config),
150
+ "today_tip_quality": evaluate_cases(_load_cases()),
151
+ }
152
+ if run_client:
153
+ report["gradio_client"] = _gradio_client_result(app_url, timeout)
154
+ report["passes"] = all(section.get("passes") for section in report.values() if isinstance(section, dict))
155
+ return report
156
+
157
+
158
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
159
+ parser = argparse.ArgumentParser(description=__doc__)
160
+ parser.add_argument("--app-url", default=DEFAULT_APP_URL)
161
+ parser.add_argument("--agents-path", default="agents.md")
162
+ parser.add_argument("--agents-url", default=DEFAULT_AGENTS_URL)
163
+ parser.add_argument("--remote-agents-doc", action="store_true", help="Read agents.md from --agents-url instead of local path.")
164
+ parser.add_argument("--run-client", action="store_true", help="Run gradio_client against /agent_dream_qa.")
165
+ parser.add_argument("--timeout", type=float, default=30.0)
166
+ return parser.parse_args(argv)
167
+
168
+
169
+ def main(argv: list[str] | None = None) -> int:
170
+ args = parse_args(argv)
171
+ report = evaluate(
172
+ app_url=args.app_url,
173
+ agents_path=None if args.remote_agents_doc else args.agents_path,
174
+ agents_url=args.agents_url,
175
+ run_client=args.run_client,
176
+ timeout=args.timeout,
177
+ )
178
+ print(json.dumps(report, ensure_ascii=False, indent=2))
179
+ return 0 if report["passes"] else 1
180
+
181
+
182
+ if __name__ == "__main__":
183
+ raise SystemExit(main())
tests/test_agent_readiness.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
7
+ assert result["passes"] is True
8
+ assert result["issues"] == []
9
+
10
+
11
+ def test_agent_api_requires_text_only_public_endpoint():
12
+ config = {
13
+ "components": [
14
+ {"id": 1, "type": "textbox"},
15
+ {"id": 2, "type": "textbox"},
16
+ {"id": 3, "type": "textbox"},
17
+ {"id": 4, "type": "textbox"},
18
+ {"id": 5, "type": "json"},
19
+ ],
20
+ "dependencies": [
21
+ {
22
+ "api_name": "agent_dream_qa",
23
+ "inputs": [1, 2, 3, 4],
24
+ "outputs": [5],
25
+ }
26
+ ],
27
+ }
28
+
29
+ result = _agent_api_result(config)
30
+
31
+ assert result["passes"] is True
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": [
38
+ {"id": 1, "type": "textbox"},
39
+ {"id": 2, "type": "image"},
40
+ {"id": 3, "type": "json"},
41
+ ],
42
+ "dependencies": [
43
+ {
44
+ "api_name": "agent_dream_qa",
45
+ "inputs": [1, 2],
46
+ "outputs": [3],
47
+ },
48
+ {"api_name": "_submit", "inputs": [1, 2], "outputs": [3]},
49
+ ],
50
+ }
51
+
52
+ result = _agent_api_result(config)
53
+
54
+ assert result["passes"] is False
55
+ assert "agent_api_requires_media_schema" in result["issues"]
56
+ assert "ui_events_exposed_as_public_api" in result["issues"]
tests/test_ui_actions.py CHANGED
@@ -36,6 +36,7 @@ def test_voice_input_keeps_modal_asr_component_but_uses_inline_mic_button():
36
  assert 'sources=["microphone", "upload"]' not in source
37
  assert 'type="filepath"' in source
38
  assert "visible=False" in source
 
39
  assert "mic_html = gr.HTML(_mic_html(DEFAULT_LANGUAGE))" in source
40
  assert "audio_input = gr.State(None)" not in source
41
  assert 'value=DEFAULT_ASR_BACKEND' in source
@@ -48,6 +49,7 @@ def test_image_upload_is_composer_plus_drawer():
48
  assert 'elem_classes=["dc-image-popover"]' in source
49
  assert 'image_input = gr.Image(' in source
50
  assert 'sources=["upload", "clipboard"]' in source
 
51
  assert "gr.Accordion(" not in source.split("image_input = gr.Image(")[0].split("with gr.Group(elem_classes=[\"dc-composer\"]):")[-1]
52
 
53
 
 
36
  assert 'sources=["microphone", "upload"]' not in source
37
  assert 'type="filepath"' in source
38
  assert "visible=False" in source
39
+ assert "_make_media_api_info_client_safe(audio_input)" in source
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
 
49
  assert 'elem_classes=["dc-image-popover"]' in source
50
  assert 'image_input = gr.Image(' in source
51
  assert 'sources=["upload", "clipboard"]' in source
52
+ assert "_make_media_api_info_client_safe(image_input)" in source
53
  assert "gr.Accordion(" not in source.split("image_input = gr.Image(")[0].split("with gr.Group(elem_classes=[\"dc-composer\"]):")[-1]
54
 
55