File size: 8,956 Bytes
b74c6ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
"""The file formats of chat memory, as pure functions: render and parse.

Layout, relative to the notebook's chat area (R2 `ws/{workspace}/chats/{notebook}/`):

    INDEX.md                    one line per topic across every chat of the notebook
    {chat_id}/TOPICS.md         this chat's topic list and the `through` pointer
    {chat_id}/{slug}.md         one file per topic: summary, then exact quotes

Every parser tolerates a missing or hand edited file by returning something empty, so one bad file never breaks a
chat."""

import re
import uuid
from dataclasses import dataclass, field

from app.corpus import slugify

TOPICS = "TOPICS.md"
INDEX = "INDEX.md"
MAX_TOPICS = 30  # lines kept in TOPICS.md; the least recently active are dropped from the list, never from the database
MAX_VERBATIM = 10
MAX_VERBATIM_CHARS = 1200
MAX_RECENT = 8  # message ids kept per topic: the last four rounds
MAX_KEYWORDS = 20

SMALL_TALK = re.compile(
    r"^\s*(hi|hey|hello|yo|hiya|good (morning|afternoon|evening)|thanks|thank you|thx|ty|cheers|ok(ay)?|"
    r"cool|great|nice|awesome|perfect|got it|sounds good|bye|goodbye|see you|lol|haha)[\s!.,:)]*$",
    re.IGNORECASE,
)
_MARKER = re.compile(r"\[(\d+)\]")
_VERBATIM_HEAD = re.compile(r"^\[([0-9a-fA-F-]{36}) (writer|assistant) (\d{4}-\d{2}-\d{2})\]$")


def chat_path(chat_id: uuid.UUID | str, name: str) -> str:
    return f"{chat_id}/{name}"


def topic_path(chat_id: uuid.UUID | str, slug: str) -> str:
    return chat_path(chat_id, f"{slug}.md")


def topic_slug(label: str) -> str:
    return slugify(label, max_len=48)


def one_line(text: str, limit: int = 200) -> str:
    """A value that sits on one `a | b | c` line: no newlines, no pipes."""
    return re.sub(r"\s+", " ", text.replace("|", "/")).strip()[:limit]


def squeeze(text: str, limit: int) -> str:
    """Shorten a long message by keeping its start and end, cut on word boundaries."""
    text = text.strip()
    if len(text) <= limit:
        return text
    half = limit // 2
    head, tail = text[:half], text[-half:]
    head = head.rsplit(" ", 1)[0] if " " in head else head
    tail = tail.split(" ", 1)[1] if " " in tail else tail
    return f"{head.rstrip()} ... {tail.lstrip()}"


def strip_markers(text: str) -> str:
    return re.sub(r"[ \t]+([.,;:!?])", r"\1", _MARKER.sub("", text))


def clean_keywords(words: list[str]) -> list[str]:
    seen: dict[str, None] = {}
    for w in words:
        w = one_line(str(w), 40).lower().replace(",", " ").strip()
        if w and w not in seen:
            seen[w] = None
    return list(seen)[:MAX_KEYWORDS]


def _front_matter(text: str) -> tuple[dict[str, str], str]:
    """(key: value pairs, the rest) of a file that starts with a --- block."""
    lines = text.splitlines()
    if not lines or lines[0].strip() != "---":
        return {}, text
    meta: dict[str, str] = {}
    for i, line in enumerate(lines[1:], start=1):
        if line.strip() == "---":
            return meta, "\n".join(lines[i + 1:])
        if ":" in line:
            key, _, value = line.partition(":")
            meta[key.strip()] = value.strip()
    return {}, text  # never closed: treat the file as unreadable front matter


# TOPICS.md


@dataclass
class TopicEntry:
    slug: str
    label: str
    last: str  # YYYY-MM-DD of the last round
    rounds: int
    keywords: str  # comma separated
    gist: str


@dataclass
class TopicsFile:
    chat_id: str = ""
    through: str = ""  # id of the last message processed, "" before the first run
    current: str = ""  # slug of the topic the newest filed round went to: what the chat was just talking about
    updated: str = ""
    entries: list[TopicEntry] = field(default_factory=list)

    def get(self, slug: str) -> TopicEntry | None:
        return next((e for e in self.entries if e.slug == slug), None)

    @property
    def slugs(self) -> set[str]:
        return {e.slug for e in self.entries}


def render_topics(tf: TopicsFile) -> str:
    entries = sorted(tf.entries, key=lambda e: e.last, reverse=True)[:MAX_TOPICS]
    lines = ["---", f"chat: {tf.chat_id}", f"through: {tf.through or 'none'}", f"current: {tf.current or 'none'}",
             f"updated: {tf.updated}", "---"]
    for e in entries:
        lines.append(f"- {e.slug} | {one_line(e.label, 80)} | {e.last} | {e.rounds} rounds | "
                     f"{one_line(e.keywords, 200)} | {one_line(e.gist, 200)}")
    return "\n".join(lines) + "\n"


def parse_topics(text: str) -> TopicsFile:
    meta, body = _front_matter(text or "")
    through = meta.get("through", "")
    current = meta.get("current", "")
    tf = TopicsFile(chat_id=meta.get("chat", ""), through="" if through in ("", "none") else through,
                    current="" if current in ("", "none") else current, updated=meta.get("updated", ""))
    for line in body.splitlines():
        if not line.startswith("- "):
            continue
        parts = [p.strip() for p in line[2:].split("|")]
        if len(parts) < 6:
            continue
        rounds = re.match(r"\d+", parts[3])
        tf.entries.append(TopicEntry(slug=parts[0], label=parts[1], last=parts[2],
                                     rounds=int(rounds.group()) if rounds else 0,
                                     keywords=parts[4], gist="|".join(parts[5:])))
    return tf


# {slug}.md


@dataclass
class Quote:
    message_id: str
    role: str  # writer | assistant
    date: str
    text: str


@dataclass
class Topic:
    label: str
    slug: str
    chat_id: str
    first: str = ""
    last: str = ""
    rounds: int = 0
    keywords: list[str] = field(default_factory=list)
    gist: str = ""
    recent: list[str] = field(default_factory=list)  # message ids, oldest first
    last_message: str = ""  # the newest message filed here: a rerun after a crash skips rounds up to it
    summary: str = ""
    verbatim: list[Quote] = field(default_factory=list)


def clean_summary(text: str, limit: int) -> str:
    """The summary sits under a `## Summary` heading, so a heading inside it would break the file's sections."""
    text = "\n".join(re.sub(r"^#+\s*", "", ln) for ln in (text or "").strip().splitlines())
    return squeeze(text, limit) if len(text) > limit else text


def render_topic(t: Topic) -> str:
    lines = ["---", f"topic: {one_line(t.label, 80)}", f"slug: {t.slug}", f"chat: {t.chat_id}", f"first: {t.first}",
             f"last: {t.last}", f"rounds: {t.rounds}", f"keywords: {', '.join(t.keywords)}",
             f"gist: {one_line(t.gist, 200)}", f"recent: {', '.join(t.recent)}", f"last_message: {t.last_message}",
             "---", "", "## Summary", "",
             t.summary.strip(), "", "## Verbatim", ""]
    for q in t.verbatim:
        lines.append(f"[{q.message_id} {q.role} {q.date}]")
        lines += [f"> {ln}" if ln else ">" for ln in q.text.splitlines()]
        lines.append("")
    return "\n".join(lines).rstrip() + "\n"


def parse_topic(text: str) -> Topic | None:
    meta, body = _front_matter(text or "")
    if not meta.get("slug"):
        return None
    t = Topic(label=meta.get("topic", meta["slug"]), slug=meta["slug"], chat_id=meta.get("chat", ""),
              first=meta.get("first", ""), last=meta.get("last", ""),
              keywords=[k.strip() for k in meta.get("keywords", "").split(",") if k.strip()],
              gist=meta.get("gist", ""), recent=[r.strip() for r in meta.get("recent", "").split(",") if r.strip()],
              last_message=meta.get("last_message", ""))
    rounds = re.match(r"\d+", meta.get("rounds", ""))
    t.rounds = int(rounds.group()) if rounds else 0
    section, summary, current = "", [], None
    for line in body.splitlines():
        if line.startswith("## "):
            section = line[3:].strip().lower()
            current = None
            continue
        if section == "summary":
            summary.append(line)
        elif section == "verbatim":
            head = _VERBATIM_HEAD.match(line)
            if head:
                current = Quote(head.group(1), head.group(2), head.group(3), "")
                t.verbatim.append(current)
            elif current is not None and line.startswith(">"):
                current.text += ("\n" if current.text else "") + line[1:].removeprefix(" ")
    t.summary = "\n".join(summary).strip()
    return t


# INDEX.md (notebook level)


def index_line(chat_id: str, entry: TopicEntry) -> str:
    return (f"{entry.last} | {topic_path(chat_id, entry.slug)} | {one_line(entry.label, 80)} | "
            f"{one_line(entry.keywords, 200)} | {one_line(entry.gist, 200)}")


def render_index(per_chat: dict[str, list[TopicEntry]]) -> str:
    """Newest first, one line per topic. Rebuilt from every chat's TOPICS.md, never edited in place."""
    rows = [(e.last, index_line(chat, e)) for chat, entries in per_chat.items() for e in entries]
    return "\n".join(line for _, line in sorted(rows, key=lambda r: r[0], reverse=True)) + ("\n" if rows else "")