aidialog 0.0.2__tar.gz → 0.0.3__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aidialog
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: Read, search, edit, and persist AI dialogs stored as Jupyter notebooks
5
5
  Author-email: Jeremy Howard <github@jhoward.fastmail.fm>
6
6
  License: Apache-2.0
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
12
12
  Requires-Python: >=3.11
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
- Requires-Dist: fastcore>=2.1.9
15
+ Requires-Dist: fastcore>=2.1.12
16
16
  Provides-Extra: dev
17
17
  Requires-Dist: exhash; extra == "dev"
18
18
  Requires-Dist: nbformat; extra == "dev"
@@ -51,7 +51,7 @@ The [`Dialog`](https://AnswerDotAI.github.io/aidialog/dialog.html#dialog) is the
51
51
  | ipynb file | storage, pragmatically lossless | [`read_ipynb`](https://AnswerDotAI.github.io/aidialog/ipynb.html#read_ipynb) | [`write_ipynb`](https://AnswerDotAI.github.io/aidialog/ipynb.html#write_ipynb) |
52
52
  | Claude Code session | storage | `sess2dlg` | `dlg2sess` |
53
53
  | Codex thread | storage (write-only so far) | | `dlg2thread` |
54
- | fastllm chat (`Msg`/`Part`) | transmission, normalizing | `chat2dlg` | `dlg2chat` |
54
+ | fastllm chat ([`Msg`](https://AnswerDotAI.github.io/aidialog/msg_parts.html#msg)/[`Part`](https://AnswerDotAI.github.io/aidialog/msg_parts.html#part)) | transmission, normalizing | `chat2dlg` | `dlg2chat` |
55
55
  | fastllm hist (live call input) | transmission, one-way | | `dlg2hist` |
56
56
  | a prompt’s reply | self-similar | `reply2dlg` | `dlg2reply` |
57
57
  | XML views | display, one-way | | [`view_dlg`](https://AnswerDotAI.github.io/aidialog/dlgskill.html#view_dlg), [`msg2xml`](https://AnswerDotAI.github.io/aidialog/dlgskill.html#msg2xml) |
@@ -31,7 +31,7 @@ The [`Dialog`](https://AnswerDotAI.github.io/aidialog/dialog.html#dialog) is the
31
31
  | ipynb file | storage, pragmatically lossless | [`read_ipynb`](https://AnswerDotAI.github.io/aidialog/ipynb.html#read_ipynb) | [`write_ipynb`](https://AnswerDotAI.github.io/aidialog/ipynb.html#write_ipynb) |
32
32
  | Claude Code session | storage | `sess2dlg` | `dlg2sess` |
33
33
  | Codex thread | storage (write-only so far) | | `dlg2thread` |
34
- | fastllm chat (`Msg`/`Part`) | transmission, normalizing | `chat2dlg` | `dlg2chat` |
34
+ | fastllm chat ([`Msg`](https://AnswerDotAI.github.io/aidialog/msg_parts.html#msg)/[`Part`](https://AnswerDotAI.github.io/aidialog/msg_parts.html#part)) | transmission, normalizing | `chat2dlg` | `dlg2chat` |
35
35
  | fastllm hist (live call input) | transmission, one-way | | `dlg2hist` |
36
36
  | a prompt’s reply | self-similar | `reply2dlg` | `dlg2reply` |
37
37
  | XML views | display, one-way | | [`view_dlg`](https://AnswerDotAI.github.io/aidialog/dlgskill.html#view_dlg), [`msg2xml`](https://AnswerDotAI.github.io/aidialog/dlgskill.html#msg2xml) |
@@ -0,0 +1,7 @@
1
+ """Read, search, edit, and persist AI dialogs stored as Jupyter notebooks
2
+
3
+ Modules:
4
+
5
+ - `aidialog.dlgskill`: Read, search, and edit dialogs and notebooks through the aidialog `Dialog`/`Message` model"""
6
+
7
+ __version__ = "0.0.3"
@@ -27,6 +27,7 @@ d = { 'settings': { 'branch': 'main',
27
27
  'aidialog.dialog.Message._neighbor': ('dialog.html#message._neighbor', 'aidialog/dialog.py'),
28
28
  'aidialog.dialog.Message._repr_markdown_': ('dialog.html#message._repr_markdown_', 'aidialog/dialog.py'),
29
29
  'aidialog.dialog.Message.add_output': ('dialog.html#message.add_output', 'aidialog/dialog.py'),
30
+ 'aidialog.dialog.Message.ai_output': ('dialog.html#message.ai_output', 'aidialog/dialog.py'),
30
31
  'aidialog.dialog.Message.ai_res': ('dialog.html#message.ai_res', 'aidialog/dialog.py'),
31
32
  'aidialog.dialog.Message.clear_inp_cache': ('dialog.html#message.clear_inp_cache', 'aidialog/dialog.py'),
32
33
  'aidialog.dialog.Message.clear_out_cache': ('dialog.html#message.clear_out_cache', 'aidialog/dialog.py'),
@@ -47,7 +48,9 @@ d = { 'settings': { 'branch': 'main',
47
48
  'aidialog.dialog.Message.previous': ('dialog.html#message.previous', 'aidialog/dialog.py'),
48
49
  'aidialog.dialog.Message.rel_msg': ('dialog.html#message.rel_msg', 'aidialog/dialog.py'),
49
50
  'aidialog.dialog.Message.remove_from_dlg': ('dialog.html#message.remove_from_dlg', 'aidialog/dialog.py'),
51
+ 'aidialog.dialog.Message.render_out': ('dialog.html#message.render_out', 'aidialog/dialog.py'),
50
52
  'aidialog.dialog.Message.summ': ('dialog.html#message.summ', 'aidialog/dialog.py'),
53
+ 'aidialog.dialog.Message.todict': ('dialog.html#message.todict', 'aidialog/dialog.py'),
51
54
  'aidialog.dialog.Message.update': ('dialog.html#message.update', 'aidialog/dialog.py'),
52
55
  'aidialog.dialog.Message.validate': ('dialog.html#message.validate', 'aidialog/dialog.py'),
53
56
  'aidialog.dialog.MsgRow': ('dialog.html#msgrow', 'aidialog/dialog.py'),
@@ -58,14 +61,19 @@ d = { 'settings': { 'branch': 'main',
58
61
  'aidialog.dialog.Msgs': ('dialog.html#msgs', 'aidialog/dialog.py'),
59
62
  'aidialog.dialog.Msgs.__repr__': ('dialog.html#msgs.__repr__', 'aidialog/dialog.py'),
60
63
  'aidialog.dialog.Msgs.show': ('dialog.html#msgs.show', 'aidialog/dialog.py'),
64
+ 'aidialog.dialog._code_span': ('dialog.html#_code_span', 'aidialog/dialog.py'),
65
+ 'aidialog.dialog._fmt_param': ('dialog.html#_fmt_param', 'aidialog/dialog.py'),
61
66
  'aidialog.dialog._get_exported': ('dialog.html#_get_exported', 'aidialog/dialog.py'),
62
67
  'aidialog.dialog._latex_parts': ('dialog.html#_latex_parts', 'aidialog/dialog.py'),
63
68
  'aidialog.dialog._set_exported': ('dialog.html#_set_exported', 'aidialog/dialog.py'),
64
69
  'aidialog.dialog.add_id_hash': ('dialog.html#add_id_hash', 'aidialog/dialog.py'),
70
+ 'aidialog.dialog.ai_fmt': ('dialog.html#ai_fmt', 'aidialog/dialog.py'),
65
71
  'aidialog.dialog.code_output': ('dialog.html#code_output', 'aidialog/dialog.py'),
66
72
  'aidialog.dialog.copy_export': ('dialog.html#copy_export', 'aidialog/dialog.py'),
67
73
  'aidialog.dialog.displayobj': ('dialog.html#displayobj', 'aidialog/dialog.py'),
74
+ 'aidialog.dialog.dlg2md': ('dialog.html#dlg2md', 'aidialog/dialog.py'),
68
75
  'aidialog.dialog.dlg2py': ('dialog.html#dlg2py', 'aidialog/dialog.py'),
76
+ 'aidialog.dialog.fmt_tools': ('dialog.html#fmt_tools', 'aidialog/dialog.py'),
69
77
  'aidialog.dialog.get_msg': ('dialog.html#get_msg', 'aidialog/dialog.py'),
70
78
  'aidialog.dialog.get_output_mds': ('dialog.html#get_output_mds', 'aidialog/dialog.py'),
71
79
  'aidialog.dialog.header_info': ('dialog.html#header_info', 'aidialog/dialog.py'),
@@ -80,13 +88,17 @@ d = { 'settings': { 'branch': 'main',
80
88
  'aidialog.dialog.mk_jmsg': ('dialog.html#mk_jmsg', 'aidialog/dialog.py'),
81
89
  'aidialog.dialog.mk_output': ('dialog.html#mk_output', 'aidialog/dialog.py'),
82
90
  'aidialog.dialog.mk_stream': ('dialog.html#mk_stream', 'aidialog/dialog.py'),
91
+ 'aidialog.dialog.msg2md': ('dialog.html#msg2md', 'aidialog/dialog.py'),
83
92
  'aidialog.dialog.normalize_text_latex': ('dialog.html#normalize_text_latex', 'aidialog/dialog.py'),
84
93
  'aidialog.dialog.output_from_msg': ('dialog.html#output_from_msg', 'aidialog/dialog.py'),
85
94
  'aidialog.dialog.prompt_output': ('dialog.html#prompt_output', 'aidialog/dialog.py'),
86
95
  'aidialog.dialog.render_output_ai': ('dialog.html#render_output_ai', 'aidialog/dialog.py'),
87
96
  'aidialog.dialog.render_outputs_ai': ('dialog.html#render_outputs_ai', 'aidialog/dialog.py'),
88
97
  'aidialog.dialog.ruuid4': ('dialog.html#ruuid4', 'aidialog/dialog.py'),
89
- 'aidialog.dialog.section_msgs': ('dialog.html#section_msgs', 'aidialog/dialog.py')},
98
+ 'aidialog.dialog.section_msgs': ('dialog.html#section_msgs', 'aidialog/dialog.py'),
99
+ 'aidialog.dialog.tool_md': ('dialog.html#tool_md', 'aidialog/dialog.py'),
100
+ 'aidialog.dialog.try_eval': ('dialog.html#try_eval', 'aidialog/dialog.py'),
101
+ 'aidialog.dialog.usage_md': ('dialog.html#usage_md', 'aidialog/dialog.py')},
90
102
  'aidialog.dlgskill': { 'aidialog.dlgskill.Dialog.copy_msgs': ('dlgskill.html#dialog.copy_msgs', 'aidialog/dlgskill.py'),
91
103
  'aidialog.dlgskill.Dialog.cut_msgs': ('dlgskill.html#dialog.cut_msgs', 'aidialog/dlgskill.py'),
92
104
  'aidialog.dlgskill.Dialog.find_msgs': ('dlgskill.html#dialog.find_msgs', 'aidialog/dlgskill.py'),
@@ -150,4 +162,40 @@ d = { 'settings': { 'branch': 'main',
150
162
  'aidialog.ipynb.read_ipynb': ('ipynb.html#read_ipynb', 'aidialog/ipynb.py'),
151
163
  'aidialog.ipynb.reads_ipynb': ('ipynb.html#reads_ipynb', 'aidialog/ipynb.py'),
152
164
  'aidialog.ipynb.split_cell_src': ('ipynb.html#split_cell_src', 'aidialog/ipynb.py'),
153
- 'aidialog.ipynb.write_ipynb': ('ipynb.html#write_ipynb', 'aidialog/ipynb.py')}}}
165
+ 'aidialog.ipynb.write_ipynb': ('ipynb.html#write_ipynb', 'aidialog/ipynb.py')},
166
+ 'aidialog.msg_parts': { 'aidialog.msg_parts.FullResponse': ('msg_parts.html#fullresponse', 'aidialog/msg_parts.py'),
167
+ 'aidialog.msg_parts.MediaUrl': ('msg_parts.html#mediaurl', 'aidialog/msg_parts.py'),
168
+ 'aidialog.msg_parts.MediaUrl.__init__': ('msg_parts.html#mediaurl.__init__', 'aidialog/msg_parts.py'),
169
+ 'aidialog.msg_parts.Msg': ('msg_parts.html#msg', 'aidialog/msg_parts.py'),
170
+ 'aidialog.msg_parts.Msg._repr_markdown_': ( 'msg_parts.html#msg._repr_markdown_',
171
+ 'aidialog/msg_parts.py'),
172
+ 'aidialog.msg_parts.Msg.text': ('msg_parts.html#msg.text', 'aidialog/msg_parts.py'),
173
+ 'aidialog.msg_parts.Part': ('msg_parts.html#part', 'aidialog/msg_parts.py'),
174
+ 'aidialog.msg_parts.Part._repr_markdown_': ( 'msg_parts.html#part._repr_markdown_',
175
+ 'aidialog/msg_parts.py'),
176
+ 'aidialog.msg_parts.StopResponse': ('msg_parts.html#stopresponse', 'aidialog/msg_parts.py'),
177
+ 'aidialog.msg_parts.ToolCall': ('msg_parts.html#toolcall', 'aidialog/msg_parts.py'),
178
+ 'aidialog.msg_parts.ToolCall._repr_markdown_': ( 'msg_parts.html#toolcall._repr_markdown_',
179
+ 'aidialog/msg_parts.py'),
180
+ 'aidialog.msg_parts._extract_tool_parts': ( 'msg_parts.html#_extract_tool_parts',
181
+ 'aidialog/msg_parts.py'),
182
+ 'aidialog.msg_parts._fetch_url_partial': ('msg_parts.html#_fetch_url_partial', 'aidialog/msg_parts.py'),
183
+ 'aidialog.msg_parts._mk_result_fence': ('msg_parts.html#_mk_result_fence', 'aidialog/msg_parts.py'),
184
+ 'aidialog.msg_parts._split_fence_msgs': ('msg_parts.html#_split_fence_msgs', 'aidialog/msg_parts.py'),
185
+ 'aidialog.msg_parts._split_msg_on_fences': ( 'msg_parts.html#_split_msg_on_fences',
186
+ 'aidialog/msg_parts.py'),
187
+ 'aidialog.msg_parts._trunc_content': ('msg_parts.html#_trunc_content', 'aidialog/msg_parts.py'),
188
+ 'aidialog.msg_parts._trunc_str': ('msg_parts.html#_trunc_str', 'aidialog/msg_parts.py'),
189
+ 'aidialog.msg_parts._trunc_strs': ('msg_parts.html#_trunc_strs', 'aidialog/msg_parts.py'),
190
+ 'aidialog.msg_parts.conv_tools': ('msg_parts.html#conv_tools', 'aidialog/msg_parts.py'),
191
+ 'aidialog.msg_parts.data_url': ('msg_parts.html#data_url', 'aidialog/msg_parts.py'),
192
+ 'aidialog.msg_parts.display_list': ('msg_parts.html#display_list', 'aidialog/msg_parts.py'),
193
+ 'aidialog.msg_parts.fmt2hist': ('msg_parts.html#fmt2hist', 'aidialog/msg_parts.py'),
194
+ 'aidialog.msg_parts.hist2fmt': ('msg_parts.html#hist2fmt', 'aidialog/msg_parts.py'),
195
+ 'aidialog.msg_parts.mk_tool_res_msg': ('msg_parts.html#mk_tool_res_msg', 'aidialog/msg_parts.py'),
196
+ 'aidialog.msg_parts.mk_tr_details': ('msg_parts.html#mk_tr_details', 'aidialog/msg_parts.py'),
197
+ 'aidialog.msg_parts.parse_tools': ('msg_parts.html#parse_tools', 'aidialog/msg_parts.py'),
198
+ 'aidialog.msg_parts.part_txt': ('msg_parts.html#part_txt', 'aidialog/msg_parts.py'),
199
+ 'aidialog.msg_parts.strip_tools': ('msg_parts.html#strip_tools', 'aidialog/msg_parts.py'),
200
+ 'aidialog.msg_parts.sys_text': ('msg_parts.html#sys_text', 'aidialog/msg_parts.py'),
201
+ 'aidialog.msg_parts.url_mime': ('msg_parts.html#url_mime', 'aidialog/msg_parts.py')}}}
@@ -2,27 +2,29 @@
2
2
 
3
3
  Docs: https://AnswerDotAI.github.io/aidialog/dialog.html.md"""
4
4
 
5
- # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_dialog.ipynb.
5
+ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/01_dialog.ipynb.
6
6
 
7
7
  # %% auto #0
8
8
  __all__ = ['smsg_types', 'scode', 'snote', 'sprompt', 'sraw', 'AI_RENDERERS', 'tiny_png', 'add_id_hash', 'Msgs', 'Dialog',
9
9
  'mk_output', 'mk_displayobj', 'displayobj', 'mk_code_output', 'code_output', 'prompt_output', 'Message',
10
10
  'MsgRow', 'MsgRows', 'get_msg', 'header_info', 'section_msgs', 'get_output_mds', 'join_out',
11
- 'normalize_text_latex', 'render_output_ai', 'render_outputs_ai', 'mk_jmsg', 'mk_stream', 'mk_error',
12
- 'mk_dispdata', 'mk_execresult', 'output_from_msg', 'dlg2py', 'copy_export', 'merge_metas', 'merge_parts',
13
- 'ruuid4', 'Attachment']
11
+ 'normalize_text_latex', 'render_output_ai', 'render_outputs_ai', 'ai_fmt', 'try_eval', 'mk_jmsg',
12
+ 'mk_stream', 'mk_error', 'mk_dispdata', 'mk_execresult', 'output_from_msg', 'dlg2py', 'copy_export',
13
+ 'merge_metas', 'merge_parts', 'ruuid4', 'Attachment', 'tool_md', 'usage_md', 'fmt_tools', 'msg2md', 'dlg2md']
14
14
 
15
- # %% ../nbs/00_dialog.ipynb #5571d07a
16
- import base64, copy, random, weakref
15
+ # %% ../nbs/01_dialog.ipynb #5571d07a
16
+ import base64, copy, random, re, weakref
17
+ from ast import literal_eval
17
18
  from json import loads, dumps
18
19
  from fastcore.utils import *
19
20
  from fastcore.ansi import strip_ansi
20
- from fastcore.nbio import mk_cell,preferred_msg_out,concat_streams
21
+ from fastcore.nbio import mk_cell,preferred_msg_out,concat_streams,render_md
22
+ from .msg_parts import strip_tools as _strip_tools
21
23
 
22
- # %% ../nbs/00_dialog.ipynb #9d79a4b9
24
+ # %% ../nbs/01_dialog.ipynb #9d79a4b9
23
25
  smsg_types = scode,snote,sprompt,sraw = "code","note","prompt","raw"
24
26
 
25
- # %% ../nbs/00_dialog.ipynb #61883528
27
+ # %% ../nbs/01_dialog.ipynb #61883528
26
28
  def add_id_hash(cls, attr):
27
29
  'Adds `__eq__` and `__hash__` to a cls based on a str or int `attr` id'
28
30
  @patch
@@ -34,7 +36,7 @@ def add_id_hash(cls, attr):
34
36
  @patch
35
37
  def __hash__(self:cls): return hash(getattr(self, attr))
36
38
 
37
- # %% ../nbs/00_dialog.ipynb #352b878b
39
+ # %% ../nbs/01_dialog.ipynb #352b878b
38
40
  class Msgs(L):
39
41
  "A list of `Message`s with a preview-per-line repr"
40
42
  def show(self,
@@ -49,7 +51,7 @@ class Msgs(L):
49
51
 
50
52
  def __repr__(self): return str(self.show(rows=20))
51
53
 
52
- # %% ../nbs/00_dialog.ipynb #bc320318
54
+ # %% ../nbs/01_dialog.ipynb #bc320318
53
55
  class Dialog(BasicRepr):
54
56
  "A named, ordered list of messages"
55
57
  def __init__(self,
@@ -84,7 +86,7 @@ class Dialog(BasicRepr):
84
86
 
85
87
  add_id_hash(Dialog, 'name')
86
88
 
87
- # %% ../nbs/00_dialog.ipynb #579ba71b
89
+ # %% ../nbs/01_dialog.ipynb #579ba71b
88
90
  def mk_output(typ, d, meta=None, **kw): return dict(output_type=typ, metadata=meta or {}, data=d, **kw)
89
91
  def mk_displayobj(d, meta=None): return mk_output('display_data', d, meta)
90
92
  def displayobj(data='', subtype='plain', mimetype='text', meta=None): return mk_displayobj({f'{mimetype}/{subtype}': data}, meta)
@@ -95,7 +97,7 @@ def prompt_output(txt=''):
95
97
  "Create prompt output list from AI response text"
96
98
  return [displayobj(txt, 'markdown', meta={'is_ai_res': True})] if txt else []
97
99
 
98
- # %% ../nbs/00_dialog.ipynb #b25cd968
100
+ # %% ../nbs/01_dialog.ipynb #b25cd968
99
101
  class Message:
100
102
  # Attribute name -> cell metadata key: fields promoted from cell metadata and serialized back (falsy omitted).
101
103
  # `skipped` (hidden from AI context) and `pinned` (kept through any context eviction) use solveit's literal keys,
@@ -177,11 +179,11 @@ class Message:
177
179
  def summ(self): return truncstr(self.content, 96) + (f" ⇒ {truncstr(str(self.output), 60)}" if self.output else '')
178
180
  __repr__ = basic_repr('id,content,output,msg_type')
179
181
 
180
- # %% ../nbs/00_dialog.ipynb #135858d9
182
+ # %% ../nbs/01_dialog.ipynb #135858d9
181
183
  add_id_hash(Message, 'id')
182
184
  Dialog.msg_cls = Message
183
185
 
184
- # %% ../nbs/00_dialog.ipynb #e3e3ab18
186
+ # %% ../nbs/01_dialog.ipynb #e3e3ab18
185
187
  @patch
186
188
  def _repr_markdown_(self:Message):
187
189
  detls = '\n'.join(f'- {k}: {getattr(self, k)}' for k in self.flds)
@@ -193,7 +195,7 @@ def _repr_markdown_(self:Message):
193
195
 
194
196
  </details>"""
195
197
 
196
- # %% ../nbs/00_dialog.ipynb #145c8a42
198
+ # %% ../nbs/01_dialog.ipynb #145c8a42
197
199
  @patch
198
200
  def preview(self:Message,
199
201
  maxlen:int=120, # Maximum characters in the line
@@ -204,7 +206,7 @@ def preview(self:Message,
204
206
  elif self.output: s += f" ⇒ out({len(str(self.output))})"
205
207
  return truncstr(s.replace('\n', '\\n'), maxlen)
206
208
 
207
- # %% ../nbs/00_dialog.ipynb #a72644ce
209
+ # %% ../nbs/01_dialog.ipynb #a72644ce
208
210
  class MsgRow:
209
211
  "Snapshot of one message: `id`, `msg_type`, `content`, `out` (reply or output text), and `meta`; shown as its preview line"
210
212
  def __init__(self, m, maxlen=120):
@@ -218,7 +220,7 @@ class MsgRows(list):
218
220
  "A list of `MsgRow`s, one preview line each"
219
221
  def __repr__(self): return '\n'.join(repr(o) for o in self)
220
222
 
221
- # %% ../nbs/00_dialog.ipynb #e4fb159d
223
+ # %% ../nbs/01_dialog.ipynb #e4fb159d
222
224
  @patch
223
225
  def update(self:Message, **kwargs):
224
226
  "Update message attributes with provided keyword arguments"
@@ -226,7 +228,7 @@ def update(self:Message, **kwargs):
226
228
  if v is not UNSET: setattr(self, k, v)
227
229
  return self
228
230
 
229
- # %% ../nbs/00_dialog.ipynb #a0465cdb
231
+ # %% ../nbs/01_dialog.ipynb #a0465cdb
230
232
  @patch
231
233
  def mk_message(self:Dialog,
232
234
  content:str, # Message text
@@ -250,14 +252,14 @@ def mk_message(self:Dialog,
250
252
  self.messages.insert(idx, msg)
251
253
  return msg
252
254
 
253
- # %% ../nbs/00_dialog.ipynb #d499654b
255
+ # %% ../nbs/01_dialog.ipynb #d499654b
254
256
  @patch
255
257
  def mk_messages(self:Dialog, msgs, after=None, before=None):
256
258
  "Make new messages and insert them sequentially into the notebook after/before a specific message."
257
259
  def _kws(m): return {o: getattr(m,o) for o in m.flds if o!='id'}
258
260
  return [after:=self.mk_message(m.content,output=m.output,attachments=m.attachments,after=after, before=before, **_kws(m)) for m in msgs]
259
261
 
260
- # %% ../nbs/00_dialog.ipynb #e668fd4f
262
+ # %% ../nbs/01_dialog.ipynb #e668fd4f
261
263
  @patch
262
264
  def insert_after(self:Message, msgs):
263
265
  curr = self.dlg.messages
@@ -265,7 +267,7 @@ def insert_after(self:Message, msgs):
265
267
  curr[idx+1:idx+1] = msgs
266
268
  for m in msgs: m.dlg = self.dlg
267
269
 
268
- # %% ../nbs/00_dialog.ipynb #0c98e2c1
270
+ # %% ../nbs/01_dialog.ipynb #0c98e2c1
269
271
  @patch
270
272
  def _neighbor(self:Message, fwd=True, pred=None):
271
273
  "Find next (fwd=True) or previous (fwd=False) message, optionally filtered by pred."
@@ -281,7 +283,7 @@ def previous(self:Message): return self._neighbor(fwd=False)
281
283
  @patch
282
284
  def rel_msg(self:Message, is_up:bool): return self._neighbor(fwd=not is_up)
283
285
 
284
- # %% ../nbs/00_dialog.ipynb #991f63bc
286
+ # %% ../nbs/01_dialog.ipynb #991f63bc
285
287
  @patch
286
288
  def find_msg(self:Dialog, content): return first(m for m in self.messages if m.content==content)
287
289
 
@@ -289,7 +291,7 @@ def get_msg(id_, dlg):
289
291
  if not dlg or not id_: return
290
292
  return first(o for o in dlg.messages if o.id==id_)
291
293
 
292
- # %% ../nbs/00_dialog.ipynb #2b6197c3
294
+ # %% ../nbs/01_dialog.ipynb #2b6197c3
293
295
  @patch
294
296
  def remove_msgs(self:Dialog, msgs):
295
297
  "Remove messages from dialog, return removed list"
@@ -302,7 +304,7 @@ def remove_from_dlg(self:Message):
302
304
  "Remove this message from its dialog"
303
305
  return self.dlg.remove_msgs([self])
304
306
 
305
- # %% ../nbs/00_dialog.ipynb #03ed3d82
307
+ # %% ../nbs/01_dialog.ipynb #03ed3d82
306
308
  _re_hdr = re.compile(r' {0,3}(?P<level>#{1,6})\s+(?P<text>.*)')
307
309
 
308
310
  def header_info(content):
@@ -326,7 +328,7 @@ def section_msgs(ms, head):
326
328
  res.append(m)
327
329
  return Msgs(res)
328
330
 
329
- # %% ../nbs/00_dialog.ipynb #e074c630
331
+ # %% ../nbs/01_dialog.ipynb #e074c630
330
332
  @patch(as_prop=True)
331
333
  def has_error(self:Message):
332
334
  if self.msg_type != scode: return False
@@ -338,7 +340,7 @@ def get_output_mds(output):
338
340
  return [o['data']['text/markdown'] for o in output
339
341
  if o.get('output_type') in ('display_data', 'execute_result') and 'text/markdown' in o.get('data', {})]
340
342
 
341
- # %% ../nbs/00_dialog.ipynb #26bfd638
343
+ # %% ../nbs/01_dialog.ipynb #26bfd638
342
344
  def join_out(d):
343
345
  "Join Jupyter's list-of-lines output data into one string"
344
346
  return ''.join(d) if isinstance(d, list) else d
@@ -366,7 +368,7 @@ AI_RENDERERS = { # chkstyle: ignore-node
366
368
  'application/javascript': lambda d: f'<script>{join_out(d)}</script>',
367
369
  }
368
370
 
369
- # %% ../nbs/00_dialog.ipynb #fe8fc0f9
371
+ # %% ../nbs/01_dialog.ipynb #fe8fc0f9
370
372
  def render_output_ai(out, renderers=None, dollars=False):
371
373
  "Plain-text rendering of one Jupyter output, as the AI sees it; `renderers` overrides/extends the per-mime table and `dollars` picks `$`-spelled math"
372
374
  r = AI_RENDERERS | ({'text/latex': lambda d: normalize_text_latex(join_out(d), dollars=True)} if dollars else {}) | (renderers or {})
@@ -380,7 +382,42 @@ def render_outputs_ai(outputs, renderers=None, dollars=False):
380
382
  return ''
381
383
  return '\n'.join(render_output_ai(o, renderers=renderers, dollars=dollars) for o in concat_streams(outputs))
382
384
 
383
- # %% ../nbs/00_dialog.ipynb #c09bb8be
385
+ # %% ../nbs/01_dialog.ipynb #027d1774
386
+ def ai_fmt(out, strip_tools=False):
387
+ "Format AI response as it will be attached to the chat history"
388
+ res = _strip_tools(out) if strip_tools else out
389
+ res = res.strip().removesuffix("\n*[Response interrupted]*").strip()
390
+ return res or '<output result="pending" reason="incomplete"/>'
391
+
392
+ # %% ../nbs/01_dialog.ipynb #d42b33a4
393
+ _prims = str, int, float, complex, bool, tuple, list, dict, set, frozenset
394
+
395
+ def try_eval(s, typ:str|None=None):
396
+ "Like `literal_eval`, but wraps in dynamic class named `typ` if succeeds, and returns `s` if fails"
397
+ try:
398
+ res = literal_eval(s)
399
+ if typ and isinstance(res, _prims): res = type(typ, (type(res),), {})(res)
400
+ return res
401
+ except: return s
402
+
403
+ # %% ../nbs/01_dialog.ipynb #37824d36
404
+ Message.ai_renderers = {}
405
+
406
+ @patch
407
+ def render_out(self:Message):
408
+ "Compute and cache the for-AI rendering of this message's output"
409
+ if self._ai_rend is not None: return
410
+ if self.msg_type in (sprompt, scode):
411
+ air = render_outputs_ai(self.output or [], renderers=self.ai_renderers)
412
+ self._ai_rend = ai_fmt(air) if self.msg_type == sprompt else str(try_eval(air))
413
+ else: self._ai_rend = ''
414
+
415
+ @patch(as_prop=True)
416
+ def ai_output(self:Message):
417
+ self.render_out()
418
+ return self._ai_rend
419
+
420
+ # %% ../nbs/01_dialog.ipynb #c09bb8be
384
421
  def mk_jmsg(typ, metadata=None, **kwargs):
385
422
  "A minimal Jupyter iopub message dict of type `typ`, with `kwargs` as its content"
386
423
  kwargs['metadata'] = metadata or {}
@@ -404,7 +441,7 @@ def output_from_msg(jmsg):
404
441
  return res
405
442
  raise ValueError(f'Unrecognized output msg type: {mt!r}')
406
443
 
407
- # %% ../nbs/00_dialog.ipynb #eb52bbdb
444
+ # %% ../nbs/01_dialog.ipynb #eb52bbdb
408
445
  @patch
409
446
  def clear_output(self:Message, wait=False):
410
447
  "Clear outputs now, or on the next `add_output` if `wait`"
@@ -438,7 +475,7 @@ def add_output(self:Message,
438
475
  return
439
476
  self.output.append(out)
440
477
 
441
- # %% ../nbs/00_dialog.ipynb #08680316
478
+ # %% ../nbs/01_dialog.ipynb #08680316
442
479
  @patch(as_prop=True)
443
480
  def directives(self:Message):
444
481
  "nbdev directives from content comments and the meta `nbdev` dict, comments winning"
@@ -459,7 +496,7 @@ def dlg2py(dlg):
459
496
  "The exported code of `dlg`, as a python source string"
460
497
  return '\n\n'.join(m.content for m in dlg.messages if m.msg_type==scode and m.exported)
461
498
 
462
- # %% ../nbs/00_dialog.ipynb #0db3221b
499
+ # %% ../nbs/01_dialog.ipynb #0db3221b
463
500
  _re_exp = re.compile(r'#\|\s*exports?[^\n]*\n')
464
501
 
465
502
  def copy_export(s, src):
@@ -467,7 +504,7 @@ def copy_export(s, src):
467
504
  m = _re_exp.match(src)
468
505
  return m.group(0)+s if m and not s.startswith('#|') else s
469
506
 
470
- # %% ../nbs/00_dialog.ipynb #860657a4
507
+ # %% ../nbs/01_dialog.ipynb #860657a4
471
508
  @patch
472
509
  def merge_content(self:Message,
473
510
  mtype:str, # The merge target's msg_type
@@ -479,7 +516,7 @@ def merge_content(self:Message,
479
516
  if self.msg_type==sraw: return f'```plaintext\n{self.content}\n```'
480
517
  return self.content
481
518
 
482
- # %% ../nbs/00_dialog.ipynb #c201c787
519
+ # %% ../nbs/01_dialog.ipynb #c201c787
483
520
  def merge_metas(metas):
484
521
  "First-wins merge of cell metadata dicts, merging the `nbdev` directives dict one level deeper"
485
522
  res,nbd = {},{}
@@ -502,7 +539,7 @@ def merge_parts(msgs, srcs=None):
502
539
  else: meta.pop('nbdev', None)
503
540
  return '\n\n'.join(c.source for c in cells), meta
504
541
 
505
- # %% ../nbs/00_dialog.ipynb #c872b4ee
542
+ # %% ../nbs/01_dialog.ipynb #c872b4ee
506
543
  _out_types = {'stream','display_data','execute_result','error'}
507
544
 
508
545
  @patch
@@ -521,7 +558,7 @@ def validate(self:Message):
521
558
  except TypeError as e: raise ValueError(f"{self.id}: meta not JSON-serializable: {e}") from None
522
559
  return self
523
560
 
524
- # %% ../nbs/00_dialog.ipynb #d07ccb3d
561
+ # %% ../nbs/01_dialog.ipynb #d07ccb3d
525
562
  @patch
526
563
  def validate(self:Dialog):
527
564
  "Validate every message plus dialog-scope rules (unique ids, serializable `meta`); returns self if fine"
@@ -532,7 +569,7 @@ def validate(self:Dialog):
532
569
  except TypeError as e: raise ValueError(f"dialog meta not JSON-serializable: {e}") from None
533
570
  return self
534
571
 
535
- # %% ../nbs/00_dialog.ipynb #1c03c386
572
+ # %% ../nbs/01_dialog.ipynb #1c03c386
536
573
  def ruuid4():
537
574
  "Generate a deterministic UUID4-like string using rtoken_hex"
538
575
  parts = [rtoken_hex(4), rtoken_hex(2), rtoken_hex(2), rtoken_hex(2), rtoken_hex(6)]
@@ -551,9 +588,88 @@ def mk_attachment(self:Message, data:bytes, content_type:str, id:str=''):
551
588
  att = Attachment(data=data, content_type=content_type, id=id)
552
589
  return self.attachments.append(att)
553
590
 
554
- # %% ../nbs/00_dialog.ipynb #18689653
591
+ # %% ../nbs/01_dialog.ipynb #18689653
555
592
  tiny_png = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==')
556
593
 
557
- # %% ../nbs/00_dialog.ipynb #036da886
594
+ # %% ../nbs/01_dialog.ipynb #036da886
558
595
  @patch
559
596
  def todict(self:Dialog): return {k:v for k,v in asdict(self).items() if k[0]!='_'}
597
+
598
+ # %% ../nbs/01_dialog.ipynb #63c06529
599
+ @patch
600
+ def todict(self:Message):
601
+ "Public stored fields plus the derived `exported` flag, with `output` as the AI-facing form"
602
+ res = AttrDict({k:v for k,v in asdict(self).items() if k[0]!='_' and k!='attachments'})
603
+ res['output'] = self.ai_output
604
+ res['exported'] = self.exported
605
+ return res
606
+
607
+ # %% ../nbs/01_dialog.ipynb #ca70a93e
608
+ from .msg_parts import tool_info as _tool_info, usage_info as _usage_info
609
+
610
+ def _fmt_param(v, mx=40):
611
+ "Compact display form of one tool arg or result value"
612
+ s = v if isinstance(v, str) else dumps(v, ensure_ascii=False)
613
+ s = s.replace('\n', '\\n')
614
+ if len(s) > mx: s = s[:mx] + '…'
615
+ return dumps(s, ensure_ascii=False)
616
+
617
+ def _code_span(txt):
618
+ "Wrap `txt` in a backtick span longer than any backtick run inside it"
619
+ ticks = '`'*(max(map(len, re.findall('`+', txt)), default=0)+1)
620
+ pad = ' ' if '`' in txt else ''
621
+ return f"{ticks}{pad}{txt}{pad}{ticks}"
622
+
623
+ _code_args = {'py':'code', 'python':'code', 'bash':'cmd'}
624
+
625
+ def tool_md(d):
626
+ "Display markdown for one parsed `{.tool}` block: a folded details div labeled `func(params)→result`, code tools shown as code"
627
+ params = ', '.join(f"{k}={_fmt_param(v)}" for k,v in (d.get('args') or {}).items())
628
+ res = d.get('result')
629
+ tail = f"→{_fmt_param(res)}" if res not in (None, '') else ''
630
+ label = _code_span(f"{d.get('name')}({params}){tail}")
631
+ code = (d.get('args') or {}).get(_code_args.get(d.get('name'), ''))
632
+ if code is not None:
633
+ body = f"Code:\n{fenced(str(code), d.get('name'))}"
634
+ if str(res or '').strip(): body += f"\n\nOutput:\n\n{fenced(str(res))}"
635
+ else: body = fenced(dumps(d, indent=2, ensure_ascii=False), 'json')
636
+ return fenced(f"## {label}\n\n{body}", ' {.details .tool-usage-details}', ch=':')
637
+
638
+ def usage_md(d):
639
+ "Display markdown for one parsed `{.usage}` block: a folded details div labeled with cost or tokens"
640
+ summ = d.get('summary') or (f"${d['cost']:.4f}" if d.get('cost') else f"{d.get('total_tokens', 0):,} tokens")
641
+ det = d.get('detail') or ' | '.join(f"{k}={v:,}" if isinstance(v, int) else f"{k}={v}"
642
+ for k,v in d.items() if v and k != 'model')
643
+ body = f"## {summ}\n\n{_code_span(det)}"
644
+ return fenced(body, ' {.details .token-usage-details}', ch=':')
645
+
646
+ def fmt_tools(s):
647
+ "Rewrite fenced JSON tool/usage wire blocks in `s` into folded `::: details` display markdown"
648
+ out, pos = [], 0
649
+ for info,body,start,end in fenced_blocks(s):
650
+ if info not in (_tool_info, _usage_info): continue
651
+ try: d = loads(body)
652
+ except Exception: continue
653
+ out.append(s[pos:start])
654
+ out.append((tool_md(d) if info == _tool_info else usage_md(d)) + '\n')
655
+ pos = end
656
+ out.append(s[pos:])
657
+ return ''.join(out)
658
+
659
+ # %% ../nbs/01_dialog.ipynb #103d7fb7
660
+ def msg2md(m):
661
+ "One message as Markdown: notes verbatim, code in a ```python fence with outputs in a `::: output` div, prompts and replies in `::: prompt`/`::: reply` divs; None for empty code"
662
+ if m.msg_type == snote: return m.content
663
+ if m.msg_type == sraw: return fenced(m.content)
664
+ if m.msg_type == sprompt:
665
+ parts = [fenced(m.content, ' prompt', ch=':')]
666
+ if (ai := (m.ai_res or '')).strip(): parts.append(fenced(fmt_tools(ai), ' reply', ch=':'))
667
+ return '\n\n'.join(parts)
668
+ if not m.content.strip(): return None
669
+ parts = [fenced(m.content, 'python')]
670
+ if (outs := render_md(m.output or [])): parts.append(fenced(outs, ' output', ch=':'))
671
+ return '\n\n'.join(parts)
672
+
673
+ def dlg2md(d):
674
+ "The dialog as one Markdown document"
675
+ return '\n\n'.join(filter(None, map(msg2md, d))) + '\n'
@@ -2,13 +2,13 @@
2
2
 
3
3
  Docs: https://AnswerDotAI.github.io/aidialog/ipynb.html.md"""
4
4
 
5
- # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/01_ipynb.ipynb.
5
+ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/02_ipynb.ipynb.
6
6
 
7
7
  # %% auto #0
8
8
  __all__ = ['reply_sep', 'att2dict', 'split_cell_src', 'get_ipynb', 'write_ipynb', 'ipynb_cells', 'dict2att', 'reads_ipynb',
9
9
  'read_ipynb']
10
10
 
11
- # %% ../nbs/01_ipynb.ipynb #d3d0463b
11
+ # %% ../nbs/02_ipynb.ipynb #d3d0463b
12
12
  from fastcore.utils import *
13
13
  from fastcore.xtras import atomic_save
14
14
  from fastcore.nbio import mk_cell,new_nb,dict2nb,read_nb,nb2str,repair_cell,repair_nb
@@ -16,10 +16,10 @@ import json
16
16
  from base64 import b64encode,b64decode
17
17
  from .dialog import *
18
18
 
19
- # %% ../nbs/01_ipynb.ipynb #aaaeb85a
19
+ # %% ../nbs/02_ipynb.ipynb #aaaeb85a
20
20
  def att2dict(att): return {att.content_type: b64encode(att.data).decode('ascii') if isinstance(att.data, bytes) else att.data}
21
21
 
22
- # %% ../nbs/01_ipynb.ipynb #3fde3c72
22
+ # %% ../nbs/02_ipynb.ipynb #3fde3c72
23
23
  reply_sep = "\n\n##### 🤖Reply🤖<!-- SOLVEIT_SEPARATOR_7f3a9b2c -->\n\n"
24
24
 
25
25
  def split_cell_src(cell):
@@ -27,14 +27,14 @@ def split_cell_src(cell):
27
27
  content, *reply = cell.get('source', '').split(reply_sep)
28
28
  return content, (reply[0] if reply else None)
29
29
 
30
- # %% ../nbs/01_ipynb.ipynb #58354f4a
30
+ # %% ../nbs/02_ipynb.ipynb #58354f4a
31
31
  _out_meta_skip = {'__type'}
32
32
 
33
33
  def _clean_out_meta(o):
34
34
  if m := o.get('metadata'): o['metadata'] = {k:v for k,v in m.items() if k not in _out_meta_skip}
35
35
  return o
36
36
 
37
- # %% ../nbs/01_ipynb.ipynb #20f31fdc
37
+ # %% ../nbs/02_ipynb.ipynb #20f31fdc
38
38
  @patch
39
39
  def cell_meta(self:Message):
40
40
  "Metadata dict to write: `meta` plus demoted `meta_attrs` fields, falsy values omitted"
@@ -44,7 +44,7 @@ def cell_meta(self:Message):
44
44
  else: meta.pop(k, None)
45
45
  return meta
46
46
 
47
- # %% ../nbs/01_ipynb.ipynb #0dde746d
47
+ # %% ../nbs/02_ipynb.ipynb #0dde746d
48
48
  @patch
49
49
  def to_cell(self:Message, version=2):
50
50
  "Convert message to a notebook cell"
@@ -65,7 +65,7 @@ def to_cell(self:Message, version=2):
65
65
  if repairs := repair_cell(cell): print('NB repair:', '; '.join(repairs))
66
66
  return cell
67
67
 
68
- # %% ../nbs/01_ipynb.ipynb #10a73cfc
68
+ # %% ../nbs/02_ipynb.ipynb #10a73cfc
69
69
  def get_ipynb(dlg:Dialog, version=2, msgs=None):
70
70
  "Notebook object for `dlg`; `msgs` defaults to all its messages"
71
71
  cells = [m.to_cell(version=version) for m in (dlg.messages if msgs is None else msgs)]
@@ -74,19 +74,19 @@ def get_ipynb(dlg:Dialog, version=2, msgs=None):
74
74
  return nb
75
75
 
76
76
 
77
- # %% ../nbs/01_ipynb.ipynb #ea5fa1dd
77
+ # %% ../nbs/02_ipynb.ipynb #ea5fa1dd
78
78
  def write_ipynb(dlg:Dialog, fname=None, version=2, msgs=None, **kwargs):
79
79
  "Write `dlg` as a notebook, or return the JSON string if `fname` is None; `kwargs` (e.g. `uid`/`gid`) pass to `atomic_save`"
80
80
  res = nb2str(get_ipynb(dlg, version=version, msgs=msgs))
81
81
  if not fname: return res
82
82
  with atomic_save(Path(fname).expanduser(), mode='w', encoding='utf-8', **kwargs) as f: f.write(res)
83
83
 
84
- # %% ../nbs/01_ipynb.ipynb #5527e596
84
+ # %% ../nbs/02_ipynb.ipynb #5527e596
85
85
  @patch
86
86
  def write(self:Dialog, base_path, version=2, msgs=None, **kwargs):
87
87
  write_ipynb(self, Path(base_path).expanduser()/f'{self.name}.ipynb', version=version, msgs=msgs, **kwargs)
88
88
 
89
- # %% ../nbs/01_ipynb.ipynb #0e16e93a
89
+ # %% ../nbs/02_ipynb.ipynb #0e16e93a
90
90
  def ipynb_cells(path, nm, prefix=None, suffix=None):
91
91
  tmpl = Path(path).expanduser()/f'{nm}.ipynb'
92
92
  if not tmpl.exists(): return []
@@ -95,7 +95,7 @@ def ipynb_cells(path, nm, prefix=None, suffix=None):
95
95
  if repairs := repair_nb(nb): print('NB repair:', '; '.join(repairs))
96
96
  return listify(prefix) + nb.cells + listify(suffix)
97
97
 
98
- # %% ../nbs/01_ipynb.ipynb #ba0bb61e
98
+ # %% ../nbs/02_ipynb.ipynb #ba0bb61e
99
99
  def dict2att(att_id, att_data):
100
100
  "Convert attachment dict to Attachment object"
101
101
  content_type, data = first(att_data.items())
@@ -106,7 +106,7 @@ def _output_from_cell(cell):
106
106
  if cell.cell_type!='code': return ''
107
107
  return getattr(cell, 'outputs', [])
108
108
 
109
- # %% ../nbs/01_ipynb.ipynb #1b43e94f
109
+ # %% ../nbs/02_ipynb.ipynb #1b43e94f
110
110
  @patch
111
111
  def cell2msg(self:Dialog, cell):
112
112
  "Convert single notebook cell to message object"
@@ -121,13 +121,13 @@ def cell2msg(self:Dialog, cell):
121
121
  id = getattr(cell, 'id', None) or rtoken_hex(4)
122
122
  return self.msg_cls(content, id=id, output=output, msg_type=msg_type, dlg=self, attachments=atts, meta=meta, **kwargs)
123
123
 
124
- # %% ../nbs/01_ipynb.ipynb #aef05275
124
+ # %% ../nbs/02_ipynb.ipynb #aef05275
125
125
  @patch
126
126
  def from_cells(self:Dialog, cells):
127
127
  self.messages = Msgs(cells).map(self.cell2msg)
128
128
  return self
129
129
 
130
- # %% ../nbs/01_ipynb.ipynb #6bdaa293
130
+ # %% ../nbs/02_ipynb.ipynb #6bdaa293
131
131
  def reads_ipynb(txt, cls=Dialog, name='dialog'):
132
132
  "Read a dialog from notebook JSON string `txt`, constructing via `cls`"
133
133
  nb = json.loads(txt)
@@ -135,7 +135,7 @@ def reads_ipynb(txt, cls=Dialog, name='dialog'):
135
135
  nb = dict2nb(nb)
136
136
  return cls(name=name, meta=dict(nb.get('metadata', {}))).from_cells(nb.cells)
137
137
 
138
- # %% ../nbs/01_ipynb.ipynb #7e8912e8
138
+ # %% ../nbs/02_ipynb.ipynb #7e8912e8
139
139
  def read_ipynb(fname, cls=Dialog, name=None):
140
140
  "Read a dialog from notebook file `fname` (`.ipynb` added if missing), constructing via `cls`; `name` defaults to the file stem"
141
141
  f = Path(fname).expanduser()
@@ -146,7 +146,7 @@ def read_ipynb(fname, cls=Dialog, name=None):
146
146
  res.path_ = f
147
147
  return res
148
148
 
149
- # %% ../nbs/01_ipynb.ipynb #87786976
149
+ # %% ../nbs/02_ipynb.ipynb #87786976
150
150
  @patch
151
151
  def save(self:Dialog, fname=None):
152
152
  "Write back to `fname`, or to the `path_` stamped by `read_ipynb`"
@@ -0,0 +1,340 @@
1
+ """The canonical LLM message model - `Msg`, `Part`, `ToolCall` - and its formatted text form
2
+
3
+ Docs: https://AnswerDotAI.github.io/aidialog/msg_parts.html.md"""
4
+
5
+ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_msg_parts.ipynb.
6
+
7
+ # %% auto #0
8
+ __all__ = ['PartType', 'tool_info', 'usage_info', 'think_start', 'think_end', 're_think', 'Part', 'Msg', 'ToolCall',
9
+ 'display_list', 'mk_tool_res_msg', 'sys_text', 'part_txt', 'data_url', 'url_mime', 'MediaUrl', 'parse_tools',
10
+ 'strip_tools', 'conv_tools', 'fmt2hist', 'StopResponse', 'FullResponse', 'mk_tr_details', 'hist2fmt']
11
+
12
+ # %% ../nbs/00_msg_parts.ipynb #a616b4f5
13
+ import json
14
+ from json import dumps
15
+ from dataclasses import dataclass, field
16
+ from fastcore.utils import *
17
+ from fastcore.xtras import detect_mime
18
+
19
+ # %% ../nbs/00_msg_parts.ipynb #2212704a
20
+ @dataclass
21
+ class Part:
22
+ "A normalized content part."
23
+ type: str
24
+ text: str = None
25
+ data: dict = None
26
+
27
+ # %% ../nbs/00_msg_parts.ipynb #5342db55
28
+ PartType = str_enum('PartType', 'text', 'thinking', 'refusal', 'tool_use', 'server_tool_result', 'tool_result',
29
+ 'input_image', 'input_audio', 'input_video', 'input_file')
30
+
31
+ # %% ../nbs/00_msg_parts.ipynb #026bfff6
32
+ def _trunc_strs(o, n=200):
33
+ "Truncate str or dict"
34
+ if not o: return o
35
+ if isinstance(o,str) and len(o)>n: return o[:100]+'...'
36
+ if isinstance(o,dict): return {k: (v[:100]+'...' if isinstance(v,str) and len(v)>n else v) for k,v in o.items()}
37
+ return o
38
+
39
+ @patch
40
+ def _repr_markdown_(self: Part):
41
+ body = _trunc_strs(self.text) if self.text else ''
42
+ data = _trunc_strs(self.data)
43
+ return f"""**Part** (`{self.type}`)
44
+
45
+ {body}
46
+
47
+ <details markdown='1'>
48
+
49
+ - data: `{data}`
50
+
51
+ </details>"""
52
+
53
+ # %% ../nbs/00_msg_parts.ipynb #ae349c18
54
+ @dataclass
55
+ class Msg:
56
+ "A normalized message."
57
+ role: str
58
+ content: List[Part]
59
+
60
+ @property
61
+ def text(self): return ''.join(p.text or '' for p in self.content if p.type == PartType.text)
62
+
63
+ def _repr_markdown_(self):
64
+ return f"""**Msg**
65
+
66
+ - role: `{self.role}`
67
+
68
+ <contents>
69
+
70
+ {'\n\n'.join(p._repr_markdown_() for p in self.content)}
71
+
72
+ </contents>"""
73
+
74
+ # %% ../nbs/00_msg_parts.ipynb #3cbac5be
75
+ @dataclass
76
+ class ToolCall:
77
+ "Normalized tool call."
78
+ id: str
79
+ name: str
80
+ arguments: dict = field(default_factory=dict)
81
+ server: bool = False
82
+ extra: dict = field(default_factory=dict)
83
+
84
+ # %% ../nbs/00_msg_parts.ipynb #195b4d89
85
+ @patch
86
+ def _repr_markdown_(self: ToolCall):
87
+ extra = _trunc_strs(self.extra)
88
+ return f"""🔧 **{self.name}**(`{self.arguments}`)
89
+
90
+ <details markdown='1'>
91
+
92
+ - id: `{self.id}`
93
+ - server: `{self.server}`
94
+ - extra: `{extra}`
95
+
96
+ </details>"""
97
+
98
+ def display_list(l):
99
+ from IPython.display import Markdown, display
100
+ display(Markdown('\n\n'.join(o._repr_markdown_() for o in l)))
101
+
102
+ # %% ../nbs/00_msg_parts.ipynb #7c88fce1
103
+ def mk_tool_res_msg(tool_calls:list[ToolCall], results:list[str|list]):
104
+ 'A util to prepare parallel tool call with str or media list results'
105
+ parts = []
106
+ for tc,res in zip(tool_calls, results):
107
+ data = dict(id=tc.id, name=tc.name, arguments=tc.arguments, server=tc.server)
108
+ parts.append(Part(type=PartType.tool_result, text=res, data=data))
109
+ return Msg(role="tool", content=parts)
110
+
111
+ # %% ../nbs/00_msg_parts.ipynb #07b612b3
112
+ def sys_text(system):
113
+ "Extract text from system (str or Part)."
114
+ if system is None: return None
115
+ return system if isinstance(system, str) else system.text
116
+
117
+ def part_txt(p): return p.text if isinstance(p,Part) else p
118
+
119
+ # %% ../nbs/00_msg_parts.ipynb #f30c7962
120
+ @flexicache(time_policy(24*3600))
121
+ def _fetch_url_partial(url, nbytes=512):
122
+ "Fetch remote media bytes, optionally only first `nbytes`."
123
+ import httpx # deliberately lazy: keeps aidialog's deps to fastcore alone (may re-base on fastcore.net later)
124
+ try:
125
+ with httpx.stream('GET', url, headers={'Range': f'bytes=0-{nbytes-1}'}, follow_redirects=True) as r:
126
+ if r.status_code not in (200, 206): return
127
+ return r.read()
128
+ except (httpx.HTTPError, httpx.InvalidURL): return
129
+
130
+ # %% ../nbs/00_msg_parts.ipynb #b6f61273
131
+ _ext_mime = {
132
+ '.jpg':'image/jpeg', '.jpeg':'image/jpeg', '.png':'image/png', '.gif':'image/gif', '.webp':'image/webp',
133
+ '.pdf':'application/pdf',
134
+ '.mp3':'audio/mpeg', '.wav':'audio/wav', '.ogg':'audio/ogg', '.flac':'audio/flac', '.m4a':'audio/mp4',
135
+ '.mp4':'video/mp4', '.mov':'video/quicktime', '.webm':'video/webm',
136
+ }
137
+
138
+ def data_url(url):
139
+ "Parse data:mime;base64,data URL into (mime, b64_data), or None."
140
+ if not isinstance(url, str) or not url.startswith('data:') or ',' not in url: return None
141
+ header, body = url.split(',', 1)
142
+ if ';base64' not in header or not body: return None
143
+ return header[5:].split(';',1)[0].strip() or 'application/octet-stream', body
144
+
145
+ def url_mime(url, default='application/octet-stream'):
146
+ "Guess mime from URL extension, and optional bytes fallback."
147
+ if "youtube.com" in url or "youtu.be" in url: return "video/mp4"
148
+ ext = '.' + url.rsplit('.', 1)[-1].split('?')[0].lower() if '.' in url.split('?')[0].split('/')[-1] else ''
149
+ if (mime:=_ext_mime.get(ext)) is None: return detect_mime(_fetch_url_partial(url))
150
+ return ifnone(mime, default)
151
+
152
+ # %% ../nbs/00_msg_parts.ipynb #189772f5
153
+ class MediaUrl(BasicRepr):
154
+ "Direct URL media reference"
155
+ def __init__(self, url, mime=None): self.url, self.mime = url, ifnone(mime, url_mime(url))
156
+
157
+ # %% ../nbs/00_msg_parts.ipynb #ee9c825e
158
+ tool_info = 'json {.tool}' # fence info string of a tool block: {id, name, args, result} (+server; `error` reserved)
159
+ usage_info = 'json {.usage}' # fence info string of a usage block: UsageStats fields
160
+
161
+ def parse_tools(s):
162
+ "Split `s` into `(text, data)` segments: `data` is a parsed `{.tool}` block dict, `None` for the final segment"
163
+ res, pos = [], 0
164
+ for info,body,start,end in fenced_blocks(s):
165
+ if info != tool_info: continue
166
+ try: d = json.loads(body)
167
+ except Exception: continue
168
+ res.append((s[pos:start], d))
169
+ pos = end
170
+ return res + [(s[pos:], None)]
171
+
172
+ def strip_tools(s, tools=True, usage=True):
173
+ "Remove `{.tool}` (and `{.usage}`) blocks from `s`"
174
+ out, pos = [], 0
175
+ for info,body,start,end in fenced_blocks(s):
176
+ if not (tools and info == tool_info) and not (usage and info == usage_info): continue
177
+ out.append(s[pos:start])
178
+ pos = end
179
+ out.append(s[pos:])
180
+ return ''.join(out)
181
+
182
+ think_start,think_end = '<!--think_start-->','<!--think_end-->'
183
+ re_think = re.compile(rf'{re.escape(think_start)}.*?{re.escape(think_end)}\n?', re.DOTALL)
184
+
185
+ # Frozen legacy envelope recognition, used only by `conv_tools`. These are the
186
+ # exact patterns fastllm shipped for the released `<details markdown='1'>`
187
+ # envelopes plus the never-released `::: {.details}` spelling.
188
+ _lg_tool_tag = "<details class='tool-usage-details' markdown='1'>"
189
+ _lg_token_tag = "<details class='token-usage-details' markdown='1'>"
190
+ _lg_tool_attrs, _lg_token_attrs = "{.details .tool-usage-details}", "{.details .token-usage-details}"
191
+ _lg_tools = re.compile(
192
+ fr"^(?:{_lg_tool_tag}\n*(?:<summary>(?P<summ1>.*?)</summary>\n*)?\n*```json\n+(?P<json1>.*?)\n+```\n+</details>"
193
+ fr"|(?P<fence>:{{3,}}) {re.escape(_lg_tool_attrs)}\n+(?:## (?P<summ2>.*?)\n+)?```json\n+(?P<json2>.*?)\n+```\n+(?P=fence)$)",
194
+ flags=re.DOTALL|re.MULTILINE)
195
+ _lg_token = re.compile(
196
+ fr"^(?:{re.escape(_lg_token_tag)}\n*<summary>(?P<tsumm1>.*?)</summary>\n*\n*`(?P<trepr1>.*?)`\n*\n*</details>"
197
+ fr"|(?P<tfence>:{{3,}}) {re.escape(_lg_token_attrs)}\n+## (?P<tsumm2>.*?)\n+`(?P<trepr2>.*?)`\n+(?P=tfence)$)\n?",
198
+ flags=re.DOTALL|re.MULTILINE)
199
+
200
+ def conv_tools(s):
201
+ "Convert legacy tool/usage envelopes in `s` (both historical spellings) to the fenced JSON wire format. Idempotent."
202
+ def _tool(m):
203
+ tj = m['json1'] if m['json1'] is not None else m['json2']
204
+ try: d = json.loads(tj.strip())
205
+ except Exception: return m[0]
206
+ call = d.get('call') or {}
207
+ res = dict(id=d.get('id'), name=call.get('function'), args=call.get('arguments') or {}, result=d.get('result'))
208
+ if d.get('server'): res['server'] = True
209
+ return fenced(dumps(res, indent=2, ensure_ascii=False), tool_info)
210
+ def _tok(m):
211
+ summ = m['tsumm1'] if m['tsumm1'] is not None else m['tsumm2']
212
+ det = m['trepr1'] if m['trepr1'] is not None else m['trepr2']
213
+ return fenced(dumps(dict(summary=summ, detail=det), ensure_ascii=False), usage_info)
214
+ return _lg_token.sub(_tok, _lg_tools.sub(_tool, s))
215
+
216
+ # %% ../nbs/00_msg_parts.ipynb #7ae2959f
217
+ _fence_back = '`````'
218
+ _result_re = re.compile(f'\n{_fence_back}result\n(.*?)\n{_fence_back}\n', re.DOTALL)
219
+
220
+
221
+ # %% ../nbs/00_msg_parts.ipynb #6d60c135
222
+ def _mk_result_fence(output): return f"\n{_fence_back}result\n{output}\n{_fence_back}\n"
223
+
224
+ def _split_msg_on_fences(msg):
225
+ "Split an assistant Msg on result fences, return list of Msgs"
226
+ if msg.role != 'assistant': return [msg]
227
+ if not _result_re.search(msg.text): return [msg]
228
+ res, asst_parts, tool_parts = [], [], []
229
+ for msg_part in msg.content:
230
+ if msg_part.type == PartType.thinking: asst_parts.append(msg_part)
231
+ elif msg_part.type == PartType.tool_use: tool_parts.append(msg_part)
232
+ elif parts := _result_re.split(msg_part.text or ''):
233
+ for i,p in enumerate(parts):
234
+ if not p: continue
235
+ if i % 2 == 0: res.append(Msg(role='assistant', content=asst_parts+[Part(type=PartType.text, text=p.strip())]))
236
+ else: res.append(Msg(role='user', content=[Part(type=PartType.text, text=_mk_result_fence(p))]))
237
+ if tool_parts: res.append(Msg(role='assistant', content=tool_parts))
238
+ return res
239
+
240
+ def _split_fence_msgs(msgs):
241
+ "Split all assistant msgs on result fences for wire protocol"
242
+ res = []
243
+ for m in msgs: res.extend(_split_msg_on_fences(m))
244
+ return res
245
+
246
+ # %% ../nbs/00_msg_parts.ipynb #ade55a7b
247
+ def _extract_tool_parts(d:dict):
248
+ "Build (tool_use_part, tool_result_part) from a parsed `{.tool}` block"
249
+ # Skip server tool calls in deserialization (round trip issues with Gemini/Anthropic)
250
+ if not d or d.get('server') or d.get('id') is None: return None
251
+ tu = Part(type=PartType.tool_use, text=None, data=dict(id=d['id'], name=d['name'], arguments=d.get('args') or {}))
252
+ tr = Part(type=PartType.tool_result, text=str(d.get('result')), data={'id': d['id'], 'name': d['name']})
253
+ return tu, tr
254
+
255
+ # %% ../nbs/00_msg_parts.ipynb #916f8df0
256
+ def fmt2hist(outp:str)->list[Msg]:
257
+ "Transform a formatted output string into fastllm canonical Msgs"
258
+ if usage_info in outp: outp = strip_tools(outp, tools=False)
259
+ if think_start in outp: outp = re_think.sub('', outp)
260
+ if tool_info not in outp:
261
+ msg = Msg(role='assistant', content=[Part(type=PartType.text, text=outp.strip() or '.')])
262
+ return _split_msg_on_fences(msg)
263
+ hist, asst_parts, tool_parts = [], [], []
264
+ def flush():
265
+ if tool_parts:
266
+ hist.append(Msg(role='assistant', content=asst_parts.copy()))
267
+ hist.append(Msg(role='tool', content=tool_parts.copy()))
268
+ asst_parts.clear()
269
+ tool_parts.clear()
270
+ for txt,d in parse_tools(outp.strip()):
271
+ if txt and txt.strip():
272
+ if tool_parts: flush()
273
+ asst_parts.append(Part(type=PartType.text, text=txt.strip() or '.'))
274
+ if d and (tp := _extract_tool_parts(d)):
275
+ asst_parts.append(tp[0])
276
+ tool_parts.append(tp[1])
277
+ flush()
278
+ if asst_parts: hist.append(Msg(role='assistant', content=asst_parts))
279
+ if not hist: hist.append(Msg(role='assistant', content=[Part(type=PartType.text, text='.')]))
280
+ result = []
281
+ for msg in hist:
282
+ if msg.role == 'assistant': result.extend(_split_msg_on_fences(msg))
283
+ else: result.append(msg)
284
+ if result[-1].role == 'tool': result.append(Msg(role='assistant', content=[Part(type=PartType.text, text='.')]))
285
+ return result
286
+
287
+ # %% ../nbs/00_msg_parts.ipynb #86265805
288
+ class StopResponse(str): pass
289
+ class FullResponse(str): pass
290
+
291
+ # %% ../nbs/00_msg_parts.ipynb #4f105e4d
292
+ def _trunc_str(s, mx=2000, skip=10, replace="TRUNCATED"):
293
+ "Truncate `s` to `mx` chars max, adding `replace` if truncated; `mx=None` disables truncation"
294
+ if mx is None or isinstance_str(s, ('FullResponse','Safe','PrettyString')): return s
295
+ if not isinstance(s, str): s = str(s)
296
+ s = type(s)(s.rstrip())
297
+ if len(s)>2 and s[0]=='𝍁' and s[-1]=='𝍁':
298
+ s = s[1:-1]
299
+ if replace: return s
300
+ if mx is None or len(s)<=mx: return s
301
+ s = s[skip:mx-skip]
302
+ ss = s.split(' ')
303
+ if len(ss[-1])>150: ss[-1] = ss[-1][:5]
304
+ s = ' '.join(ss)
305
+ if skip: s = f"…{s}"
306
+ s = f"{s}…"
307
+ if replace: s = f"<{replace}>{s}</{replace}>"
308
+ return s
309
+
310
+ # %% ../nbs/00_msg_parts.ipynb #3f2a30e7
311
+ def _trunc_content(content, mx):
312
+ "Truncate tool result content, respecting '_full' flag"
313
+ if isinstance(content, dict) and '_full' in content and len(content)==1: return content['_full']
314
+ return _trunc_str(content, mx=mx)
315
+
316
+ # %% ../nbs/00_msg_parts.ipynb #f08c8cbd
317
+ def mk_tr_details(tr, mx=2000):
318
+ "Create the `{.tool}` wire block for a tool call; `mx=None` disables truncation"
319
+ args = {k:_trunc_str(v, mx=None if mx is None else mx*5) if isinstance(v, str) else v for k,v in tr.data['arguments'].items()}
320
+ res = dict(id=tr.data['id'], name=tr.data['name'], args=args, result=_trunc_content(tr.text, mx=mx))
321
+ if tr.data.get('server'): res['server'] = True
322
+ return "\n\n" + fenced(dumps(res, indent=2, ensure_ascii=False), tool_info) + "\n\n"
323
+
324
+ # %% ../nbs/00_msg_parts.ipynb #e5acd16a
325
+ def hist2fmt(msgs:list[Msg], mx=2000)->str:
326
+ "Render assistant/tool `msgs` as one formatted output string, the inverse of `fmt2hist`"
327
+ tus, out = {}, []
328
+ for m in msgs:
329
+ if m.role == 'assistant':
330
+ for p in m.content:
331
+ if p.type == PartType.text and p.text: out.append(p.text.strip())
332
+ elif p.type == PartType.tool_use: tus[p.data['id']] = p
333
+ elif m.role == 'tool':
334
+ for p in m.content:
335
+ if p.type != PartType.tool_result: continue
336
+ tu = tus.get(p.data['id'])
337
+ d = dict(p.data, arguments=tu.data.get('arguments', {}) if tu else {})
338
+ out.append(mk_tr_details(Part(type=PartType.tool_result, text=p.text, data=d), mx=mx).strip())
339
+ else: raise ValueError(f"hist2fmt renders assistant and tool messages only, got {m.role!r}")
340
+ return '\n\n'.join(o for o in out if o)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aidialog
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: Read, search, edit, and persist AI dialogs stored as Jupyter notebooks
5
5
  Author-email: Jeremy Howard <github@jhoward.fastmail.fm>
6
6
  License: Apache-2.0
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
12
12
  Requires-Python: >=3.11
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
- Requires-Dist: fastcore>=2.1.9
15
+ Requires-Dist: fastcore>=2.1.12
16
16
  Provides-Extra: dev
17
17
  Requires-Dist: exhash; extra == "dev"
18
18
  Requires-Dist: nbformat; extra == "dev"
@@ -51,7 +51,7 @@ The [`Dialog`](https://AnswerDotAI.github.io/aidialog/dialog.html#dialog) is the
51
51
  | ipynb file | storage, pragmatically lossless | [`read_ipynb`](https://AnswerDotAI.github.io/aidialog/ipynb.html#read_ipynb) | [`write_ipynb`](https://AnswerDotAI.github.io/aidialog/ipynb.html#write_ipynb) |
52
52
  | Claude Code session | storage | `sess2dlg` | `dlg2sess` |
53
53
  | Codex thread | storage (write-only so far) | | `dlg2thread` |
54
- | fastllm chat (`Msg`/`Part`) | transmission, normalizing | `chat2dlg` | `dlg2chat` |
54
+ | fastllm chat ([`Msg`](https://AnswerDotAI.github.io/aidialog/msg_parts.html#msg)/[`Part`](https://AnswerDotAI.github.io/aidialog/msg_parts.html#part)) | transmission, normalizing | `chat2dlg` | `dlg2chat` |
55
55
  | fastllm hist (live call input) | transmission, one-way | | `dlg2hist` |
56
56
  | a prompt’s reply | self-similar | `reply2dlg` | `dlg2reply` |
57
57
  | XML views | display, one-way | | [`view_dlg`](https://AnswerDotAI.github.io/aidialog/dlgskill.html#view_dlg), [`msg2xml`](https://AnswerDotAI.github.io/aidialog/dlgskill.html#msg2xml) |
@@ -7,6 +7,7 @@ aidialog/_modidx.py
7
7
  aidialog/dialog.py
8
8
  aidialog/dlgskill.py
9
9
  aidialog/ipynb.py
10
+ aidialog/msg_parts.py
10
11
  aidialog.egg-info/PKG-INFO
11
12
  aidialog.egg-info/SOURCES.txt
12
13
  aidialog.egg-info/dependency_links.txt
@@ -1,4 +1,4 @@
1
- fastcore>=2.1.9
1
+ fastcore>=2.1.12
2
2
 
3
3
  [dev]
4
4
  exhash
@@ -15,7 +15,7 @@ classifiers = [
15
15
  "Programming Language :: Python :: 3",
16
16
  "Programming Language :: Python :: 3 :: Only",
17
17
  ]
18
- dependencies = ['fastcore>=2.1.9']
18
+ dependencies = ['fastcore>=2.1.12']
19
19
 
20
20
  [project.optional-dependencies]
21
21
  dev = ['exhash', 'nbformat']
@@ -1 +0,0 @@
1
- __version__ = "0.0.2"
File without changes
File without changes
File without changes
File without changes