@researai/deepscientist 1.5.13 → 1.5.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +8 -0
  2. package/assets/branding/logo-raster.png +0 -0
  3. package/bin/ds.js +134 -49
  4. package/docs/en/00_QUICK_START.md +2 -2
  5. package/docs/en/01_SETTINGS_REFERENCE.md +20 -4
  6. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +19 -0
  7. package/docs/en/05_TUI_GUIDE.md +466 -96
  8. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
  9. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +2 -0
  10. package/docs/en/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
  11. package/docs/en/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
  12. package/docs/en/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
  13. package/docs/en/README.md +8 -0
  14. package/docs/zh/00_QUICK_START.md +2 -2
  15. package/docs/zh/01_SETTINGS_REFERENCE.md +20 -4
  16. package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +19 -0
  17. package/docs/zh/05_TUI_GUIDE.md +465 -82
  18. package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
  19. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +2 -0
  20. package/docs/zh/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
  21. package/docs/zh/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
  22. package/docs/zh/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
  23. package/docs/zh/README.md +8 -0
  24. package/install.sh +2 -0
  25. package/package.json +1 -1
  26. package/pyproject.toml +1 -1
  27. package/src/deepscientist/__init__.py +1 -1
  28. package/src/deepscientist/artifact/charts.py +567 -0
  29. package/src/deepscientist/artifact/guidance.py +50 -10
  30. package/src/deepscientist/artifact/metrics.py +228 -5
  31. package/src/deepscientist/artifact/schemas.py +3 -0
  32. package/src/deepscientist/artifact/service.py +4004 -538
  33. package/src/deepscientist/bash_exec/models.py +23 -0
  34. package/src/deepscientist/bash_exec/monitor.py +147 -67
  35. package/src/deepscientist/bash_exec/runtime.py +218 -156
  36. package/src/deepscientist/bash_exec/service.py +79 -64
  37. package/src/deepscientist/bash_exec/shells.py +87 -0
  38. package/src/deepscientist/bridges/connectors.py +51 -2
  39. package/src/deepscientist/config/models.py +6 -3
  40. package/src/deepscientist/config/service.py +7 -2
  41. package/src/deepscientist/connector/lingzhu_support.py +23 -4
  42. package/src/deepscientist/connector/weixin_support.py +122 -1
  43. package/src/deepscientist/daemon/api/handlers.py +75 -4
  44. package/src/deepscientist/daemon/api/router.py +1 -0
  45. package/src/deepscientist/daemon/app.py +869 -236
  46. package/src/deepscientist/doctor.py +51 -0
  47. package/src/deepscientist/file_lock.py +48 -0
  48. package/src/deepscientist/gitops/diff.py +167 -1
  49. package/src/deepscientist/mcp/server.py +331 -21
  50. package/src/deepscientist/process_control.py +161 -0
  51. package/src/deepscientist/prompts/builder.py +275 -491
  52. package/src/deepscientist/quest/service.py +2336 -145
  53. package/src/deepscientist/quest/stage_views.py +305 -29
  54. package/src/deepscientist/runners/base.py +2 -0
  55. package/src/deepscientist/runners/codex.py +88 -5
  56. package/src/deepscientist/runners/runtime_overrides.py +17 -1
  57. package/src/deepscientist/shared.py +6 -1
  58. package/src/prompts/contracts/shared_interaction.md +13 -4
  59. package/src/prompts/system.md +984 -1985
  60. package/src/skills/analysis-campaign/SKILL.md +31 -2
  61. package/src/skills/analysis-campaign/references/artifact-orchestration.md +1 -1
  62. package/src/skills/analysis-campaign/references/writing-facing-slice-examples.md +65 -0
  63. package/src/skills/baseline/SKILL.md +267 -994
  64. package/src/skills/baseline/references/baseline-checklist-template.md +21 -32
  65. package/src/skills/baseline/references/baseline-plan-template.md +41 -57
  66. package/src/skills/decision/SKILL.md +19 -2
  67. package/src/skills/experiment/SKILL.md +8 -2
  68. package/src/skills/finalize/SKILL.md +18 -0
  69. package/src/skills/idea/SKILL.md +78 -0
  70. package/src/skills/idea/references/idea-generation-playbook.md +100 -0
  71. package/src/skills/idea/references/outline-seeding-example.md +60 -0
  72. package/src/skills/intake-audit/SKILL.md +1 -1
  73. package/src/skills/optimize/SKILL.md +1644 -0
  74. package/src/skills/rebuttal/SKILL.md +2 -1
  75. package/src/skills/review/SKILL.md +2 -1
  76. package/src/skills/write/SKILL.md +80 -12
  77. package/src/skills/write/references/outline-evidence-contract-example.md +107 -0
  78. package/src/tui/dist/app/AppContainer.js +1445 -52
  79. package/src/tui/dist/components/Composer.js +1 -1
  80. package/src/tui/dist/components/ConfigScreen.js +190 -36
  81. package/src/tui/dist/components/GradientStatusText.js +1 -20
  82. package/src/tui/dist/components/InputPrompt.js +41 -32
  83. package/src/tui/dist/components/LoadingIndicator.js +1 -1
  84. package/src/tui/dist/components/Logo.js +61 -38
  85. package/src/tui/dist/components/MainContent.js +10 -3
  86. package/src/tui/dist/components/WelcomePanel.js +4 -12
  87. package/src/tui/dist/components/messages/AssistantMessage.js +1 -1
  88. package/src/tui/dist/components/messages/BashExecOperationMessage.js +3 -3
  89. package/src/tui/dist/components/messages/OperationMessage.js +1 -1
  90. package/src/tui/dist/index.js +28 -1
  91. package/src/tui/dist/layouts/DefaultAppLayout.js +3 -3
  92. package/src/tui/dist/lib/api.js +17 -0
  93. package/src/tui/dist/lib/connectors.js +261 -0
  94. package/src/tui/dist/semantic-colors.js +29 -19
  95. package/src/tui/package.json +1 -1
  96. package/src/ui/dist/assets/{AiManusChatView-CnJcXynW.js → AiManusChatView-DDjbFnbt.js} +12 -12
  97. package/src/ui/dist/assets/{AnalysisPlugin-DeyzPEhV.js → AnalysisPlugin-Yb5IdmaU.js} +1 -1
  98. package/src/ui/dist/assets/CliPlugin-e64sreyu.js +31037 -0
  99. package/src/ui/dist/assets/{CodeEditorPlugin-B-xicq1e.js → CodeEditorPlugin-C4D2TIkU.js} +8 -8
  100. package/src/ui/dist/assets/{CodeViewerPlugin-DT54ysXa.js → CodeViewerPlugin-BVoNZIvC.js} +5 -5
  101. package/src/ui/dist/assets/{DocViewerPlugin-DQtKT-VD.js → DocViewerPlugin-CLChbllo.js} +3 -3
  102. package/src/ui/dist/assets/{GitDiffViewerPlugin-hqHbCfnv.js → GitDiffViewerPlugin-C4xeFyFQ.js} +20 -20
  103. package/src/ui/dist/assets/{ImageViewerPlugin-OcVo33jV.js → ImageViewerPlugin-OiMUAcLi.js} +5 -5
  104. package/src/ui/dist/assets/{LabCopilotPanel-DdGwhEUV.js → LabCopilotPanel-BjD2ThQF.js} +11 -11
  105. package/src/ui/dist/assets/{LabPlugin-Ciz1gDaX.js → LabPlugin-DQPg-NrB.js} +2 -2
  106. package/src/ui/dist/assets/{LatexPlugin-BhmjNQRC.js → LatexPlugin-CI05XAV9.js} +7 -7
  107. package/src/ui/dist/assets/{MarkdownViewerPlugin-BzdVH9Bx.js → MarkdownViewerPlugin-DpeBLYZf.js} +4 -4
  108. package/src/ui/dist/assets/{MarketplacePlugin-DmyHspXt.js → MarketplacePlugin-DolE58Q2.js} +3 -3
  109. package/src/ui/dist/assets/{NotebookEditor-BTVYRGkm.js → NotebookEditor-7Qm2rSWD.js} +11 -11
  110. package/src/ui/dist/assets/{NotebookEditor-BMXKrDRk.js → NotebookEditor-C1kWaxKi.js} +1 -1
  111. package/src/ui/dist/assets/{PdfLoader-CvcjJHXv.js → PdfLoader-BfOHw8Zw.js} +1 -1
  112. package/src/ui/dist/assets/{PdfMarkdownPlugin-DW2ej8Vk.js → PdfMarkdownPlugin-BulDREv1.js} +2 -2
  113. package/src/ui/dist/assets/{PdfViewerPlugin-CmlDxbhU.js → PdfViewerPlugin-C-daaOaL.js} +10 -10
  114. package/src/ui/dist/assets/{SearchPlugin-DAjQZPSv.js → SearchPlugin-CjpaiJ3A.js} +1 -1
  115. package/src/ui/dist/assets/{TextViewerPlugin-C-nVAZb_.js → TextViewerPlugin-BxIyqPQC.js} +5 -5
  116. package/src/ui/dist/assets/{VNCViewer-D7-dIYon.js → VNCViewer-HAg9mF7M.js} +10 -10
  117. package/src/ui/dist/assets/{bot-C_G4WtNI.js → bot-0DYntytV.js} +1 -1
  118. package/src/ui/dist/assets/{code-Cd7WfiWq.js → code-B20Slj_w.js} +1 -1
  119. package/src/ui/dist/assets/{file-content-B57zsL9y.js → file-content-DT24KFma.js} +1 -1
  120. package/src/ui/dist/assets/{file-diff-panel-DVoheLFq.js → file-diff-panel-DK13YPql.js} +1 -1
  121. package/src/ui/dist/assets/{file-socket-B5kXFxZP.js → file-socket-B4T2o4nR.js} +1 -1
  122. package/src/ui/dist/assets/{image-LLOjkMHF.js → image-DSeR_sDS.js} +1 -1
  123. package/src/ui/dist/assets/{index-hOUOWbW2.js → index-BrFje2Uk.js} +2 -2
  124. package/src/ui/dist/assets/{index-Dxa2eYMY.js → index-BwRJaoTl.js} +1 -1
  125. package/src/ui/dist/assets/{index-CLQauncb.js → index-D_E4281X.js} +5418 -28620
  126. package/src/ui/dist/assets/{index-C3r2iGrp.js → index-DnYB3xb1.js} +12 -12
  127. package/src/ui/dist/assets/{index-BQG-1s2o.css → index-G7AcWcMu.css} +43 -2
  128. package/src/ui/dist/assets/{monaco-BGGAEii3.js → monaco-LExaAN3Y.js} +1 -1
  129. package/src/ui/dist/assets/{pdf-effect-queue-DlEr1_y5.js → pdf-effect-queue-BJk5okWJ.js} +1 -1
  130. package/src/ui/dist/assets/{popover-CWJbJuYY.js → popover-D3Gg_FoV.js} +1 -1
  131. package/src/ui/dist/assets/{project-sync-CRJiucYO.js → project-sync-C_ygLlVU.js} +1 -1
  132. package/src/ui/dist/assets/{select-CoHB7pvH.js → select-CpAK6uWm.js} +2 -2
  133. package/src/ui/dist/assets/{sigma-D5aJWR8J.js → sigma-DEccaSgk.js} +1 -1
  134. package/src/ui/dist/assets/{square-check-big-DUK_mnkS.js → square-check-big-uUfyVsbD.js} +1 -1
  135. package/src/ui/dist/assets/{trash-ChU3SEE3.js → trash-CXvwwSe8.js} +1 -1
  136. package/src/ui/dist/assets/{useCliAccess-BrJBV3tY.js → useCliAccess-Bnop4mgR.js} +1 -1
  137. package/src/ui/dist/assets/{useFileDiffOverlay-C2OQaVWc.js → useFileDiffOverlay-B8eUAX0I.js} +1 -1
  138. package/src/ui/dist/assets/{wrap-text-C7Qqh-om.js → wrap-text-9vbOBpkW.js} +1 -1
  139. package/src/ui/dist/assets/{zoom-out-rtX0FKya.js → zoom-out-BgVMmOW4.js} +1 -1
  140. package/src/ui/dist/index.html +2 -2
  141. package/uv.lock +1 -1
  142. package/src/ui/dist/assets/CliPlugin-CB1YODQn.js +0 -5905
@@ -0,0 +1,567 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import math
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from PIL import Image, ImageDraw, ImageFont
9
+
10
+
11
+ MORANDI_BG = "#F6F1EA"
12
+ MORANDI_PANEL = "#FFFDF8"
13
+ MORANDI_GRID = "#E2D8CB"
14
+ MORANDI_AXIS = "#A99A8A"
15
+ MORANDI_TEXT = "#4F4942"
16
+ MORANDI_TEXT_MUTED = "#7A736A"
17
+ MORANDI_BLUE = "#445F7D"
18
+ MORANDI_RED = "#8F5C62"
19
+ MORANDI_GOLD = "#C2A15C"
20
+ MORANDI_GOLD_STROKE = "#A78549"
21
+ MORANDI_BORDER = "#DED1C0"
22
+ MORANDI_SOFT_BLUE = "#D7E0E6"
23
+ MORANDI_SOFT_RED = "#E7D5D8"
24
+ MORANDI_CARD = "#FBF7F1"
25
+ MORANDI_PLOT_BG = "#FFFCF8"
26
+
27
+
28
+ def render_main_experiment_metric_timeline_chart(
29
+ *,
30
+ series: dict[str, Any],
31
+ output_path: Path,
32
+ style: str = "branded",
33
+ ) -> dict[str, Any]:
34
+ normalized_style = "branded"
35
+ baseline = _select_baseline(series)
36
+ points = [dict(item) for item in (series.get("points") or []) if isinstance(item, dict)]
37
+ direction = _normalize_direction(series.get("direction"))
38
+ label = str(series.get("label") or series.get("metric_id") or "Metric").strip() or "Metric"
39
+ metric_id = str(series.get("metric_id") or label).strip() or label
40
+ decimals = series.get("decimals") if isinstance(series.get("decimals"), int) else None
41
+ unit = str(series.get("unit") or "").strip() or None
42
+ human_label = _humanize_metric_label(label)
43
+
44
+ width = 1360
45
+ height = 820
46
+ header_height = 178
47
+ footer_height = 132
48
+ summary_width = 0
49
+ padding = {"left": 92, "right": 56, "top": 38, "bottom": 42}
50
+ plot_left = padding["left"]
51
+ plot_top = header_height
52
+ plot_right = width - 54
53
+ plot_bottom = height - footer_height
54
+ plot_width = plot_right - plot_left
55
+ plot_height = plot_bottom - plot_top
56
+
57
+ values = [
58
+ float(item["value"])
59
+ for item in points
60
+ if isinstance(item.get("value"), (int, float)) and math.isfinite(float(item["value"]))
61
+ ]
62
+ if isinstance(baseline.get("value"), (int, float)) and math.isfinite(float(baseline["value"])):
63
+ values.append(float(baseline["value"]))
64
+ if not values:
65
+ values = [0.0, 1.0]
66
+ min_value = min(values)
67
+ max_value = max(values)
68
+ if math.isclose(min_value, max_value):
69
+ min_value -= 1.0
70
+ max_value += 1.0
71
+ padding_value = (max_value - min_value) * 0.12
72
+ min_value -= padding_value
73
+ max_value += padding_value
74
+ value_range = max(max_value - min_value, 1e-9)
75
+
76
+ image = Image.new("RGBA", (width, height), MORANDI_BG)
77
+ draw = ImageDraw.Draw(image)
78
+ draw.rounded_rectangle(
79
+ (18, 18, width - 18, height - 18),
80
+ radius=30,
81
+ fill=MORANDI_PANEL,
82
+ outline=MORANDI_GRID,
83
+ width=1,
84
+ )
85
+ draw.rounded_rectangle(
86
+ (plot_left - 18, plot_top - 18, plot_right + 18, plot_bottom + 18),
87
+ radius=26,
88
+ fill=MORANDI_PLOT_BG,
89
+ outline=MORANDI_BORDER,
90
+ width=1,
91
+ )
92
+
93
+ title_font = _load_font(38, bold=True)
94
+ subtitle_font = _load_font(18, bold=False)
95
+ axis_font = _load_font(16, bold=False)
96
+ badge_font = _load_font(17, bold=True)
97
+ card_label_font = _load_font(15, bold=True)
98
+ _draw_brand_lockup(draw, image, width=width)
99
+
100
+ title = human_label
101
+ subtitle_parts = [metric_id]
102
+ subtitle_parts.append("higher is better" if direction == "maximize" else "lower is better")
103
+ if unit:
104
+ subtitle_parts.append(unit)
105
+ subtitle = " · ".join(subtitle_parts)
106
+
107
+ draw.text((52, 44), title, fill=MORANDI_TEXT, font=title_font)
108
+ draw.text((52, 92), subtitle, fill=MORANDI_TEXT_MUTED, font=subtitle_font)
109
+ _draw_header_badges(
110
+ draw,
111
+ x=52,
112
+ y=122,
113
+ labels=[
114
+ "Main Experiment",
115
+ f"{len(points)} runs",
116
+ "Baseline reference" if isinstance(baseline.get("value"), (int, float)) else "No baseline line",
117
+ ],
118
+ font=badge_font,
119
+ )
120
+
121
+ for step in range(5):
122
+ ratio = step / 4 if 4 else 0
123
+ y = plot_top + plot_height - ratio * plot_height
124
+ draw.line((plot_left, y, plot_right, y), fill=MORANDI_GRID, width=1)
125
+ value = min_value + ratio * value_range
126
+ label_text = _format_metric_value(value, decimals)
127
+ bbox = draw.textbbox((0, 0), label_text, font=axis_font)
128
+ draw.text(
129
+ (plot_left - 16 - (bbox[2] - bbox[0]), y - (bbox[3] - bbox[1]) / 2),
130
+ label_text,
131
+ fill=MORANDI_TEXT_MUTED,
132
+ font=axis_font,
133
+ )
134
+
135
+ axis_color = MORANDI_AXIS
136
+ draw.line((plot_left, plot_bottom, plot_right, plot_bottom), fill=axis_color, width=2)
137
+ draw.line((plot_left, plot_top, plot_left, plot_bottom), fill=axis_color, width=2)
138
+
139
+ if isinstance(baseline.get("value"), (int, float)) and math.isfinite(float(baseline["value"])):
140
+ baseline_y = _value_to_y(float(baseline["value"]), plot_top, plot_height, min_value, value_range)
141
+ _draw_dashed_line(
142
+ draw,
143
+ (plot_left, baseline_y),
144
+ (plot_right, baseline_y),
145
+ fill=MORANDI_GOLD,
146
+ width=3,
147
+ dash=12,
148
+ gap=8,
149
+ )
150
+
151
+ point_positions: list[tuple[float, float, dict[str, Any], bool, bool]] = []
152
+ point_slots: list[dict[str, Any]] = []
153
+ if isinstance(baseline.get("value"), (int, float)) and math.isfinite(float(baseline["value"])):
154
+ point_slots.append(
155
+ {
156
+ "slot_key": "baseline",
157
+ "display_label": "Base",
158
+ "value": float(baseline["value"]),
159
+ "delta_vs_baseline": 0.0,
160
+ "baseline_slot": True,
161
+ }
162
+ )
163
+ for point in points:
164
+ point_slots.append(
165
+ {
166
+ "slot_key": point.get("seq"),
167
+ "display_label": f"R{point.get('seq') or len(point_slots)}",
168
+ "value": point.get("value"),
169
+ "delta_vs_baseline": point.get("delta_vs_baseline"),
170
+ "baseline_slot": False,
171
+ **point,
172
+ }
173
+ )
174
+ x_count = max(1, len(point_slots))
175
+ for index, point in enumerate(point_slots):
176
+ value = point.get("value")
177
+ if not isinstance(value, (int, float)) or not math.isfinite(float(value)):
178
+ continue
179
+ x = plot_left if x_count == 1 else plot_left + (index / (x_count - 1)) * plot_width
180
+ y = _value_to_y(float(value), plot_top, plot_height, min_value, value_range)
181
+ beats_baseline = _beats_baseline(
182
+ value=float(value),
183
+ baseline_value=float(baseline["value"]) if isinstance(baseline.get("value"), (int, float)) else None,
184
+ delta=point.get("delta_vs_baseline"),
185
+ direction=direction,
186
+ ) if not bool(point.get("baseline_slot")) else False
187
+ point_positions.append((x, y, point, beats_baseline, bool(point.get("baseline_slot"))))
188
+
189
+ for index in range(1, len(point_positions)):
190
+ x0, y0, *_ = point_positions[index - 1]
191
+ x1, y1, *_ = point_positions[index]
192
+ draw.line((x0, y0, x1, y1), fill=MORANDI_BLUE, width=4)
193
+
194
+ latest_index = len(point_positions) - 1
195
+ for index, (x, y, point, beats_baseline, baseline_slot) in enumerate(point_positions):
196
+ fill = MORANDI_GOLD if baseline_slot else MORANDI_RED if index == latest_index else MORANDI_BLUE
197
+ radius = 8 if baseline_slot else 9 if index == latest_index else 7
198
+ draw.ellipse((x - radius, y - radius, x + radius, y + radius), fill=fill, outline=MORANDI_PANEL, width=3)
199
+ if beats_baseline:
200
+ _draw_star(draw, x, y - 18, outer_radius=10, inner_radius=4.2, fill=MORANDI_GOLD, outline=MORANDI_GOLD_STROKE)
201
+ tick_label = str(point.get("display_label") or f"R{index + 1}")
202
+ bbox = draw.textbbox((0, 0), tick_label, font=axis_font)
203
+ draw.text((x - (bbox[2] - bbox[0]) / 2, plot_bottom + 12), tick_label, fill=MORANDI_TEXT_MUTED, font=axis_font)
204
+
205
+ _draw_plot_legend(
206
+ draw,
207
+ x=plot_right - 4,
208
+ y=plot_top - 50,
209
+ baseline_value=float(baseline["value"]) if isinstance(baseline.get("value"), (int, float)) else None,
210
+ decimals=decimals,
211
+ font=badge_font,
212
+ )
213
+
214
+ latest_value = point_positions[-1][2].get("value") if point_positions else None
215
+ latest_delta = point_positions[-1][2].get("delta_vs_baseline") if point_positions else None
216
+ latest_beats_baseline = point_positions[-1][3] if point_positions else False
217
+ _draw_footer_summary(
218
+ draw,
219
+ left=plot_left,
220
+ top=plot_bottom + 36,
221
+ width=plot_right - plot_left,
222
+ label_font=card_label_font,
223
+ badge_font=badge_font,
224
+ baseline_label=str(baseline.get("label") or "Baseline").strip() or "Baseline",
225
+ baseline_value=baseline.get("value"),
226
+ latest_value=latest_value,
227
+ delta_value=latest_delta,
228
+ decimals=decimals,
229
+ direction=direction,
230
+ beats_baseline=latest_beats_baseline,
231
+ unit=unit,
232
+ )
233
+
234
+ if point_positions:
235
+ latest_x, latest_y, latest_point, _, latest_is_baseline = point_positions[-1]
236
+ latest_value = latest_point.get("value")
237
+ if not latest_is_baseline:
238
+ latest_text = _format_metric_value(latest_value, decimals)
239
+ _draw_latest_value_callout(
240
+ draw,
241
+ x=latest_x,
242
+ y=latest_y,
243
+ value_text=latest_text,
244
+ font=badge_font,
245
+ fill=MORANDI_SOFT_RED if latest_beats_baseline else MORANDI_CARD,
246
+ )
247
+
248
+ ensure_parent(output_path)
249
+ image.save(output_path, format="PNG")
250
+ return {
251
+ "metric_id": metric_id,
252
+ "label": human_label,
253
+ "path": str(output_path),
254
+ "baseline_value": baseline.get("value"),
255
+ "latest_value": next((point[2].get("value") for point in reversed(point_positions) if not point[4]), None),
256
+ "point_count": len(point_positions),
257
+ "direction": direction,
258
+ "style": "branded",
259
+ }
260
+
261
+
262
+ def ensure_parent(path: Path) -> None:
263
+ path.parent.mkdir(parents=True, exist_ok=True)
264
+
265
+
266
+ def _select_baseline(series: dict[str, Any]) -> dict[str, Any]:
267
+ baselines = [dict(item) for item in (series.get("baselines") or []) if isinstance(item, dict)]
268
+ selected = next(
269
+ (
270
+ item
271
+ for item in baselines
272
+ if bool(item.get("selected"))
273
+ and isinstance(item.get("value"), (int, float))
274
+ and math.isfinite(float(item["value"]))
275
+ ),
276
+ None,
277
+ )
278
+ if selected is not None:
279
+ return selected
280
+ fallback = next(
281
+ (
282
+ item
283
+ for item in baselines
284
+ if isinstance(item.get("value"), (int, float))
285
+ and math.isfinite(float(item["value"]))
286
+ ),
287
+ None,
288
+ )
289
+ return fallback or {}
290
+
291
+
292
+ def _normalize_direction(value: object) -> str:
293
+ text = str(value or "").strip().lower().replace("-", "_").replace(" ", "_")
294
+ if text in {"lower", "minimize", "lower_better", "less_is_better"}:
295
+ return "minimize"
296
+ return "maximize"
297
+
298
+
299
+ def _format_metric_value(value: object, decimals: int | None) -> str:
300
+ if not isinstance(value, (int, float)) or not math.isfinite(float(value)):
301
+ return "—"
302
+ number = float(value)
303
+ if isinstance(decimals, int):
304
+ return f"{number:.{decimals}f}"
305
+ rendered = f"{number:.4f}"
306
+ return rendered.rstrip("0").rstrip(".")
307
+
308
+
309
+ def _humanize_metric_label(value: str) -> str:
310
+ parts = [part for part in str(value or "").replace("-", "_").split("_") if part]
311
+ if not parts:
312
+ return "Metric"
313
+ return " ".join(part.upper() if len(part) <= 3 else part.capitalize() for part in parts)
314
+
315
+
316
+ def _beats_baseline(*, value: float, baseline_value: float | None, delta: object, direction: str) -> bool:
317
+ if baseline_value is not None and math.isfinite(baseline_value):
318
+ return value < baseline_value if direction == "minimize" else value > baseline_value
319
+ if isinstance(delta, (int, float)) and math.isfinite(float(delta)):
320
+ return float(delta) < 0 if direction == "minimize" else float(delta) > 0
321
+ return False
322
+
323
+
324
+ def _value_to_y(value: float, plot_top: float, plot_height: float, min_value: float, value_range: float) -> float:
325
+ return plot_top + plot_height - ((value - min_value) / value_range) * plot_height
326
+
327
+
328
+ def _draw_dashed_line(
329
+ draw: ImageDraw.ImageDraw,
330
+ start: tuple[float, float],
331
+ end: tuple[float, float],
332
+ *,
333
+ fill: str,
334
+ width: int,
335
+ dash: int,
336
+ gap: int,
337
+ ) -> None:
338
+ x0, y0 = start
339
+ x1, y1 = end
340
+ total = math.dist((x0, y0), (x1, y1))
341
+ if total <= 0:
342
+ return
343
+ dx = (x1 - x0) / total
344
+ dy = (y1 - y0) / total
345
+ progress = 0.0
346
+ while progress < total:
347
+ segment = min(progress + dash, total)
348
+ draw.line(
349
+ (
350
+ x0 + dx * progress,
351
+ y0 + dy * progress,
352
+ x0 + dx * segment,
353
+ y0 + dy * segment,
354
+ ),
355
+ fill=fill,
356
+ width=width,
357
+ )
358
+ progress += dash + gap
359
+
360
+
361
+ def _draw_star(
362
+ draw: ImageDraw.ImageDraw,
363
+ cx: float,
364
+ cy: float,
365
+ *,
366
+ outer_radius: float,
367
+ inner_radius: float,
368
+ fill: str,
369
+ outline: str,
370
+ ) -> None:
371
+ points: list[tuple[float, float]] = []
372
+ for index in range(10):
373
+ angle = -math.pi / 2 + (index * math.pi) / 5
374
+ radius = outer_radius if index % 2 == 0 else inner_radius
375
+ points.append((cx + math.cos(angle) * radius, cy + math.sin(angle) * radius))
376
+ draw.polygon(points, fill=fill, outline=outline)
377
+
378
+
379
+ def _draw_brand_lockup(draw: ImageDraw.ImageDraw, image: Image.Image, *, width: int) -> None:
380
+ mark = _load_brand_mark()
381
+ text_right = width - 52
382
+ brand_font = _load_font(22, bold=True)
383
+ caption_font = _load_font(13, bold=False)
384
+ text_bbox = draw.textbbox((0, 0), "DeepScientist", font=brand_font)
385
+ caption_bbox = draw.textbbox((0, 0), "Autonomous Research Update", font=caption_font)
386
+ content_width = max(text_bbox[2] - text_bbox[0], caption_bbox[2] - caption_bbox[0])
387
+ mark_width = mark.width if mark is not None else 0
388
+ total_width = content_width + (mark_width + 12 if mark is not None else 0)
389
+ left = max(52, text_right - total_width)
390
+ if mark is not None:
391
+ image.alpha_composite(mark, (left, 42))
392
+ text_left = left + (mark_width + 12 if mark is not None else 0)
393
+ draw.text((text_left, 46), "DeepScientist", fill=MORANDI_TEXT, font=brand_font)
394
+ draw.text((text_left, 74), "Autonomous Research Update", fill=MORANDI_TEXT_MUTED, font=caption_font)
395
+
396
+
397
+ def _draw_header_badges(
398
+ draw: ImageDraw.ImageDraw,
399
+ *,
400
+ x: int,
401
+ y: int,
402
+ labels: list[str],
403
+ font: ImageFont.ImageFont,
404
+ ) -> None:
405
+ cursor_x = x
406
+ for label in labels:
407
+ if not label:
408
+ continue
409
+ text = str(label).strip()
410
+ bbox = draw.textbbox((0, 0), text, font=font)
411
+ width = (bbox[2] - bbox[0]) + 24
412
+ draw.rounded_rectangle(
413
+ (cursor_x, y, cursor_x + width, y + 30),
414
+ radius=14,
415
+ fill=MORANDI_CARD,
416
+ outline=MORANDI_BORDER,
417
+ width=1,
418
+ )
419
+ draw.text((cursor_x + 12, y + 6), text, fill=MORANDI_TEXT_MUTED, font=font)
420
+ cursor_x += width + 10
421
+
422
+
423
+ def _draw_plot_legend(
424
+ draw: ImageDraw.ImageDraw,
425
+ *,
426
+ x: int,
427
+ y: int,
428
+ baseline_value: float | None,
429
+ decimals: int | None,
430
+ font: ImageFont.ImageFont,
431
+ ) -> None:
432
+ if baseline_value is None:
433
+ return
434
+ label = f"Baseline · {_format_metric_value(baseline_value, decimals)}"
435
+ bbox = draw.textbbox((0, 0), label, font=font)
436
+ width = (bbox[2] - bbox[0]) + 48
437
+ left = x - width
438
+ draw.rounded_rectangle(
439
+ (left, y, x, y + 30),
440
+ radius=15,
441
+ fill="#FFF7E9",
442
+ outline="#E7D2A7",
443
+ width=1,
444
+ )
445
+ _draw_dashed_line(draw, (left + 12, y + 15), (left + 34, y + 15), fill=MORANDI_GOLD, width=3, dash=7, gap=5)
446
+ draw.text((left + 42, y + 6), label, fill=MORANDI_GOLD_STROKE, font=font)
447
+
448
+
449
+ def _draw_footer_summary(
450
+ draw: ImageDraw.ImageDraw,
451
+ *,
452
+ left: int,
453
+ top: int,
454
+ width: int,
455
+ label_font: ImageFont.ImageFont,
456
+ badge_font: ImageFont.ImageFont,
457
+ baseline_label: str,
458
+ baseline_value: object,
459
+ latest_value: object,
460
+ delta_value: object,
461
+ decimals: int | None,
462
+ direction: str,
463
+ beats_baseline: bool,
464
+ unit: str | None,
465
+ ) -> None:
466
+ card_height = 54
467
+ gap = 14
468
+ value_font = _load_font(24, bold=True)
469
+ cards = [
470
+ ("Latest", _format_metric_value(latest_value, decimals), MORANDI_SOFT_RED if beats_baseline else MORANDI_CARD),
471
+ ("Baseline", _format_metric_value(baseline_value, decimals), "#FFF7E9"),
472
+ ("Delta", _format_metric_value(delta_value, decimals) if isinstance(delta_value, (int, float)) else "—", MORANDI_SOFT_BLUE),
473
+ ]
474
+ card_width = int((width - gap * 2) / 3)
475
+ for index, (title, value, fill) in enumerate(cards):
476
+ x = left + index * (card_width + gap)
477
+ y = top
478
+ draw.rounded_rectangle(
479
+ (x, y, x + card_width, y + card_height),
480
+ radius=18,
481
+ fill=fill,
482
+ outline=MORANDI_BORDER,
483
+ width=1,
484
+ )
485
+ draw.text((x + 16, y + 12), title, fill=MORANDI_TEXT_MUTED, font=label_font)
486
+ draw.text((x + 102, y + 8), value, fill=MORANDI_TEXT, font=value_font)
487
+ if title == "Baseline":
488
+ draw.text((x + 16, y + 36), _truncate_text(baseline_label, 24), fill=MORANDI_TEXT_MUTED, font=badge_font)
489
+ elif title == "Delta":
490
+ hint = "lower is better" if direction == "minimize" else "higher is better"
491
+ draw.text((x + 16, y + 36), hint, fill=MORANDI_TEXT_MUTED, font=badge_font)
492
+ elif title == "Latest":
493
+ latest_hint = "beats baseline" if beats_baseline else "latest recorded point"
494
+ if unit:
495
+ latest_hint = f"{latest_hint} · {unit}"
496
+ draw.text((x + 16, y + 36), latest_hint, fill=MORANDI_TEXT_MUTED, font=badge_font)
497
+
498
+
499
+ def _draw_latest_value_callout(
500
+ draw: ImageDraw.ImageDraw,
501
+ *,
502
+ x: float,
503
+ y: float,
504
+ value_text: str,
505
+ font: ImageFont.ImageFont,
506
+ fill: str,
507
+ ) -> None:
508
+ label = f"Latest {value_text}"
509
+ bbox = draw.textbbox((0, 0), label, font=font)
510
+ width = (bbox[2] - bbox[0]) + 24
511
+ height = 28
512
+ left = x - width / 2
513
+ top = y - 42
514
+ draw.rounded_rectangle(
515
+ (left, top, left + width, top + height),
516
+ radius=14,
517
+ fill=fill,
518
+ outline=MORANDI_BORDER,
519
+ width=1,
520
+ )
521
+ draw.text((left + 12, top + 5), label, fill=MORANDI_TEXT, font=font)
522
+
523
+
524
+ def _truncate_text(value: str, limit: int) -> str:
525
+ glyphs = list(value)
526
+ if len(glyphs) <= limit:
527
+ return value
528
+ return "".join(glyphs[: max(0, limit - 1)]).rstrip() + "…"
529
+
530
+
531
+ @functools.lru_cache(maxsize=1)
532
+ def _load_brand_mark() -> Image.Image | None:
533
+ candidates = [
534
+ Path(__file__).resolve().parents[3] / "assets" / "branding" / "logo-raster.png",
535
+ Path(__file__).resolve().parents[3] / "assets" / "branding" / "deepscientist-mark.png",
536
+ ]
537
+ for path in candidates:
538
+ if not path.exists():
539
+ continue
540
+ try:
541
+ image = Image.open(path).convert("RGBA")
542
+ alpha = image.getchannel("A")
543
+ bbox = alpha.getbbox()
544
+ if bbox:
545
+ image = image.crop(bbox)
546
+ image.thumbnail((42, 42))
547
+ return image
548
+ except Exception:
549
+ continue
550
+ return None
551
+
552
+
553
+ def _load_font(size: int, *, bold: bool) -> ImageFont.ImageFont:
554
+ candidates = [
555
+ "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
556
+ "/usr/share/fonts/opentype/noto/NotoSans-Bold.ttf" if bold else "/usr/share/fonts/opentype/noto/NotoSans-Regular.ttf",
557
+ "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
558
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
559
+ "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/dejavu/DejaVuSans.ttf",
560
+ ]
561
+ for path in candidates:
562
+ if Path(path).exists():
563
+ try:
564
+ return ImageFont.truetype(path, size=size)
565
+ except Exception:
566
+ continue
567
+ return ImageFont.load_default()
@@ -114,12 +114,13 @@ def build_guidance_for_record(record: dict[str, Any]) -> dict[str, Any]:
114
114
  flow_type = str(record.get("flow_type") or "").strip().lower()
115
115
  protocol_step = str(record.get("protocol_step") or "").strip().lower()
116
116
  if flow_type == "baseline_gate" and protocol_step == "confirm":
117
+ next_skill = "idea" if _need_research_paper_from_record(record) else "optimize"
117
118
  return _guidance(
118
119
  current_anchor="baseline",
119
- recommended_skill="idea",
120
+ recommended_skill=next_skill,
120
121
  recommended_action="continue",
121
- summary="Baseline gate confirmed. Move into idea selection relative to the accepted baseline.",
122
- why_now="The accepted baseline is now explicitly available as the downstream comparison anchor, so ideation is the next real leverage point.",
122
+ summary="Baseline gate confirmed. Move into the next algorithmic route-selection stage relative to the accepted baseline.",
123
+ why_now="The accepted baseline is now explicitly available as the downstream comparison anchor, so the next leverage point is to choose and promote the strongest next direction.",
123
124
  complete_when=[
124
125
  "At least one candidate idea is recorded durably.",
125
126
  "A decision artifact selects, rejects, or branches the current direction.",
@@ -165,23 +166,62 @@ def build_guidance_for_record(record: dict[str, Any]) -> dict[str, Any]:
165
166
  if kind == "idea":
166
167
  flow_type = str(record.get("flow_type") or "").strip().lower()
167
168
  protocol_step = str(record.get("protocol_step") or "").strip().lower()
169
+ if flow_type == "idea_submission" and protocol_step == "candidate":
170
+ return _guidance(
171
+ current_anchor="idea",
172
+ recommended_skill="optimize",
173
+ recommended_action="continue",
174
+ summary="Candidate idea recorded. Compare it against the other candidate briefs before promoting a durable branch.",
175
+ why_now="This candidate is a lightweight optimization brief rather than a committed research line. Rank or refine the candidate pool first, then promote only the strongest directions into durable branches.",
176
+ complete_when=[
177
+ "The candidate pool is narrowed to the strongest 1 to 3 directions.",
178
+ "Any promoted direction is resubmitted as a durable line with `submission_mode='line'`.",
179
+ ],
180
+ alternative_routes=[
181
+ _route("continue", "Refine candidate pool", "Several candidate briefs still overlap or lack a clear winner.", "Improves selection quality, but delays implementation."),
182
+ _route("launch_experiment", "Promote immediately", "This candidate is already clearly stronger than the alternatives.", "Moves faster, but risks under-explored alternatives."),
183
+ ],
184
+ suggested_artifact_calls=[
185
+ _artifact_call(
186
+ "artifact.submit_idea(mode='create', submission_mode='line', source_candidate_id=..., lineage_intent='continue_line'|'branch_alternative', ...)",
187
+ "Promote the chosen candidate brief into a durable optimization line.",
188
+ ),
189
+ _artifact_call("artifact.record(kind='decision', ...)", "Record why a candidate was promoted, deferred, or rejected."),
190
+ ],
191
+ source_artifact_kind=kind,
192
+ source_artifact_id=artifact_id,
193
+ related_paths=[str(path) for path in related_paths],
194
+ )
168
195
  if flow_type == "idea_submission" and protocol_step in {"create", "revise"}:
196
+ details = dict(record.get("details") or {}) if isinstance(record.get("details"), dict) else {}
197
+ next_target = _normalize_anchor(details.get("next_target") or record.get("next_target") or "experiment")
198
+ recommended_skill = next_target if next_target in {
199
+ "scout",
200
+ "baseline",
201
+ "idea",
202
+ "optimize",
203
+ "experiment",
204
+ "analysis-campaign",
205
+ "write",
206
+ "finalize",
207
+ "decision",
208
+ } else "experiment"
169
209
  return _guidance(
170
210
  current_anchor="idea",
171
- recommended_skill="experiment",
172
- recommended_action="launch_experiment",
173
- summary="Idea branch is ready. Continue with the main experiment on this active research node.",
174
- why_now="The accepted idea already has its durable branch/worktree, so the next leverage point is evidence production rather than another route-selection loop.",
211
+ recommended_skill=recommended_skill,
212
+ recommended_action="continue" if recommended_skill == "optimize" else "launch_experiment",
213
+ summary="Idea branch is ready. Continue with the next active optimization stage on this durable research node.",
214
+ why_now="The accepted idea already has its durable branch/worktree, so the next leverage point is the configured next stage rather than another route-selection loop.",
175
215
  complete_when=[
176
- "A main experiment is recorded on this idea branch.",
177
- "Metrics versus baseline are written durably.",
216
+ "The next configured stage starts from this branch.",
217
+ "The resulting evidence or decision is written durably.",
178
218
  ],
179
219
  alternative_routes=[
180
220
  _route("continue", "Inspect the branch once", "A quick branch sanity check is still needed before running.", "Adds caution, but should stay short."),
181
221
  _route("launch_analysis_campaign", "Analyze first", "The idea package still has unresolved setup ambiguity that needs clarification.", "Can reduce wasted runs, but is unusual before a first main result."),
182
222
  ],
183
223
  suggested_artifact_calls=[
184
- _artifact_call("artifact.record_main_experiment(...)", "Record the first real main result on this branch."),
224
+ _artifact_call("artifact.record_main_experiment(...)", "Record the first real main result on this branch when the next stage is experiment-oriented."),
185
225
  ],
186
226
  source_artifact_kind=kind,
187
227
  source_artifact_id=artifact_id,