Coverage for src/lilbee/app/session_export.py: 100%

143 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""Render a saved chat session as a markdown document and write it to disk.""" 

2 

3from __future__ import annotations 

4 

5import os 

6import re 

7from collections.abc import Iterator 

8from functools import cache 

9from pathlib import Path 

10 

11import yaml 

12from markdown_it import MarkdownIt 

13from markdown_it.rules_inline.html_inline import html_inline as _stock_html_inline 

14from markdown_it.rules_inline.state_inline import StateInline 

15from markdown_it.token import Token 

16 

17from lilbee.app.session_sources import message_sources 

18from lilbee.core.security import write_private_text 

19from lilbee.core.text import collapse_whitespace, make_slug 

20from lilbee.retrieval.query.formatting import ( 

21 FILE_LINK_RE, 

22 SourceRef, 

23 append_sources_block, 

24 close_open_fence, 

25 located, 

26 markdown_text, 

27 split_sources_list, 

28) 

29from lilbee.sessions import MessageRole, Session, SessionMessage, SessionMeta 

30 

31_EXPORT_SUFFIX = ".md" 

32SLUG_MAX_LEN = 60 

33_FALLBACK_STEM = "chat" 

34_ID_PREFIX_LEN = 8 

35_FRONT_MATTER_FENCE = "---" 

36_ROLE_HEADINGS: dict[MessageRole, str] = { 

37 MessageRole.USER: "User", 

38 MessageRole.ASSISTANT: "Assistant", 

39} 

40_ATX_MARKER = "#" 

41_TURN_HEADING_LEVEL = 2 

42_MAX_HEADING_LEVEL = 6 

43_ESCAPE = "\\" 

44# The only characters a markdown blank line may hold. 

45_MARKDOWN_BLANK = " \t" 

46_LINE_BREAK_RE = re.compile(r"\r\n?") 

47# An HTML heading's ``<h``/``</h`` plus level; a boundary after the digit (or the 

48# text simply ending there) is what excludes ``<header>``, ``<hr>`` and ``<h2o>``. 

49_HTML_HEADING_RE = re.compile(r"<(?P<slash>/?)(?P<tag>[Hh])(?P<level>[1-6])(?=[\s/>]|$)") 

50 

51 

52def session_markdown(session: Session) -> str: 

53 """The session as markdown: front matter, a title heading, one section per turn.""" 

54 parts = [_front_matter(session.meta), f"# {collapse_whitespace(session.meta.title)}"] 

55 parts.extend(_message_section(message) for message in session.messages) 

56 return "\n\n".join(parts) + "\n" 

57 

58 

59def _front_matter(meta: SessionMeta) -> str: 

60 fields = { 

61 "title": meta.title, 

62 "session": meta.id, 

63 "model": meta.model_ref, 

64 "created": meta.created_at, 

65 "updated": meta.updated_at, 

66 } 

67 if meta.forked_from: 

68 fields["forked_from"] = meta.forked_from 

69 dumped = yaml.safe_dump(fields, sort_keys=False, allow_unicode=True) 

70 return f"{_FRONT_MATTER_FENCE}\n{dumped}{_FRONT_MATTER_FENCE}" 

71 

72 

73def _message_section(message: SessionMessage) -> str: 

74 """One turn: the message with its headings nested, then its Sources list without links.""" 

75 content = _LINE_BREAK_RE.sub("\n", message.content).rstrip() 

76 if message.source_refs is None: 

77 body = _legacy_body(content, message) 

78 else: 

79 body = _listed(_contained(content), message_sources(message)[1]) 

80 return f"## {_ROLE_HEADINGS[message.role]}\n\n{body}" 

81 

82 

83def _legacy_body(content: str, message: SessionMessage) -> str: 

84 """The body of a turn saved before ``source_refs``: its text, then the Sources 

85 list it ends in with its links made plain, or the list of its ``sources``.""" 

86 text, stored_sources = split_sources_list(content) 

87 body = _contained(text.rstrip()) 

88 if stored_sources: 

89 return body + _contained(FILE_LINK_RE.sub(_plain_link, stored_sources)) 

90 return _listed(body, message_sources(message)[1]) 

91 

92 

93def _listed(body: str, refs: list[SourceRef]) -> str: 

94 """*body* ending in the Sources list of *refs*, each named by its label and locator.""" 

95 if not refs: 

96 return body 

97 return append_sources_block( 

98 body, refs, render=lambda ref: markdown_text(located(ref.label, ref)) 

99 ) 

100 

101 

102def _contained(text: str) -> str: 

103 """*text* with open fences closed and headings nested, so it stays inside its turn.""" 

104 return _nest_headings(close_open_fence(text)) 

105 

106 

107def _plain_link(link: re.Match[str]) -> str: 

108 """A stored Sources link as its label alone, as markdown that shows the label.""" 

109 return markdown_text(link["label"]) 

110 

111 

112def _html_inline_with_span(state: StateInline, silent: bool) -> bool: 

113 """The stock ``html_inline`` rule, plus the ``state.src`` span it matched on ``token.meta``.""" 

114 start = state.pos 

115 matched = _stock_html_inline(state, silent) 

116 if matched and not silent and state.tokens and state.tokens[-1].type == "html_inline": 

117 state.tokens[-1].meta = {"start": start, "end": state.pos} 

118 return matched 

119 

120 

121@cache 

122def _heading_parser() -> MarkdownIt: 

123 """A CommonMark parser whose ``html_inline`` tokens carry the source span they matched.""" 

124 md = MarkdownIt("commonmark") 

125 md.inline.ruler.at("html_inline", _html_inline_with_span) 

126 return md 

127 

128 

129def _nest_headings(text: str) -> str: 

130 """*text* with no heading, markdown or HTML, at or above the turn level. 

131 

132 The parser finds the headings, so a ``#`` in code or a ``#tag`` stays as 

133 written, and an HTML heading tag inside a fence or an inline code span 

134 stays as written too. A ``#`` heading drops two levels, to at most six; 

135 an underlined heading cannot go below level two, so its underline is 

136 escaped to text, and a blank line ends that text where the heading 

137 ended. An HTML heading tag, open or close, drops the same two levels, 

138 independently of whether it is ever closed. 

139 """ 

140 lines = text.split("\n") 

141 paragraph_ends: list[tuple[int, str | None]] = [] 

142 for token in _heading_parser().parse(text): 

143 if token.map: 

144 start, end = token.map 

145 paragraph_ends.append((end - 1, _nest_block(lines, token, start, end))) 

146 for last_line, blank_line in paragraph_ends: 

147 if blank_line is not None: 

148 lines[last_line] += "\n" + blank_line 

149 return "\n".join(lines) 

150 

151 

152def _nest_block(lines: list[str], token: Token, start: int, end: int) -> str | None: 

153 """Rewrite *token*'s lines *start* to *end*; the blank line to add after them, if any.""" 

154 if token.type == "heading_open": 

155 return _nest_heading(lines, token.markup, start, end) 

156 if token.type == "html_block": 

157 _demote_html_block(lines, start, end) 

158 elif token.type == "inline": 

159 _demote_html_inline(lines, token.content, token.children, start, end) 

160 return None 

161 

162 

163def _nest_heading(lines: list[str], markup: str, start: int, end: int) -> str | None: 

164 """Rewrite the heading on lines *start* to *end*; the blank line to add after it, if any.""" 

165 if markup.startswith(_ATX_MARKER): 

166 level = min(len(markup) + _TURN_HEADING_LEVEL, _MAX_HEADING_LEVEL) 

167 lines[start] = lines[start].replace(markup, _ATX_MARKER * level, 1) 

168 return None 

169 return _escape_underline(lines, markup, end) 

170 

171 

172def _escape_underline(lines: list[str], markup: str, end: int) -> str | None: 

173 """Escape the underline before line *end*; the blank line that keeps the next block, if any.""" 

174 underline = end - 1 

175 container, _, rest = lines[underline].partition(markup) 

176 lines[underline] = container + _ESCAPE + markup + rest 

177 if end < len(lines) and lines[end].strip(_MARKDOWN_BLANK): 

178 return container.rstrip() 

179 return None 

180 

181 

182def _demote_html_tag(match: re.Match[str]) -> str: 

183 """*match*, an HTML heading tag's opening chars, with its level raised by the turn offset.""" 

184 level = min(int(match["level"]) + _TURN_HEADING_LEVEL, _MAX_HEADING_LEVEL) 

185 return f"<{match['slash']}{match['tag']}{level}" 

186 

187 

188def _demote_html_block(lines: list[str], start: int, end: int) -> None: 

189 """Rewrite any HTML heading tag's level in the raw HTML block spanning *start* to *end*.""" 

190 joined = "\n".join(lines[start:end]) 

191 lines[start:end] = _HTML_HEADING_RE.sub(_demote_html_tag, joined).split("\n") 

192 

193 

194def _demote_html_inline( 

195 lines: list[str], content: str, children: list[Token] | None, start: int, end: int 

196) -> None: 

197 """Rewrite each real HTML heading tag's level within the inline span *start* to *end*.""" 

198 spans = [child.meta for child in children or () if child.type == "html_inline" and child.meta] 

199 if not spans: 

200 return 

201 real = _inside_spans(_HTML_HEADING_RE.finditer(content), spans) 

202 if not any(real): 

203 return 

204 raw = "\n".join(lines[start:end]) 

205 pieces: list[str] = [] 

206 cursor = 0 

207 # The parser strips only markers and whitespace and turns NUL into U+FFFD: matches pair up. 

208 for match, is_real in zip(_HTML_HEADING_RE.finditer(raw), real, strict=True): 

209 if is_real: 

210 pieces += [raw[cursor : match.start()], _demote_html_tag(match)] 

211 cursor = match.end() 

212 pieces.append(raw[cursor:]) 

213 lines[start:end] = "".join(pieces).split("\n") 

214 

215 

216def _inside_spans(matches: Iterator[re.Match[str]], spans: list[dict[str, int]]) -> list[bool]: 

217 """Whether each of *matches*, in order, starts inside one of the ordered *spans*.""" 

218 inside = [] 

219 spans_left = iter(spans) 

220 span = next(spans_left, None) 

221 for match in matches: 

222 while span is not None and span["end"] <= match.start(): 

223 span = next(spans_left, None) 

224 inside.append(span is not None and span["start"] <= match.start()) 

225 return inside 

226 

227 

228def default_export_name(meta: SessionMeta) -> str: 

229 """``<title-slug>-<id prefix>.md``, or ``chat-<id prefix>.md`` when the title has no slug.""" 

230 slug = make_slug(meta.title)[:SLUG_MAX_LEN].strip("-") or _FALLBACK_STEM 

231 return f"{slug}-{meta.id[:_ID_PREFIX_LEN]}{_EXPORT_SUFFIX}" 

232 

233 

234def write_session_markdown(session: Session, destination: str) -> Path: 

235 """Write *session* owner-only to *destination*, or to its default name inside 

236 it when it is a directory or ends in a separator. Returns the absolute path.""" 

237 target = Path(destination).expanduser() 

238 if target.is_dir() or destination.endswith(("/", os.sep)): 

239 target = target / default_export_name(session.meta) 

240 target = target.resolve() 

241 write_private_text(target, session_markdown(session)) 

242 return target