convoviz 0.4.1__py3-none-any.whl
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.
- convoviz/__init__.py +34 -0
- convoviz/__main__.py +6 -0
- convoviz/analysis/__init__.py +22 -0
- convoviz/analysis/graphs.py +879 -0
- convoviz/analysis/wordcloud.py +204 -0
- convoviz/assets/colormaps.txt +15 -0
- convoviz/assets/fonts/AmaticSC-Regular.ttf +0 -0
- convoviz/assets/fonts/ArchitectsDaughter-Regular.ttf +0 -0
- convoviz/assets/fonts/BebasNeue-Regular.ttf +0 -0
- convoviz/assets/fonts/Borel-Regular.ttf +0 -0
- convoviz/assets/fonts/Courgette-Regular.ttf +0 -0
- convoviz/assets/fonts/CroissantOne-Regular.ttf +0 -0
- convoviz/assets/fonts/Handjet-Regular.ttf +0 -0
- convoviz/assets/fonts/IndieFlower-Regular.ttf +0 -0
- convoviz/assets/fonts/Kalam-Regular.ttf +0 -0
- convoviz/assets/fonts/Lobster-Regular.ttf +0 -0
- convoviz/assets/fonts/MartianMono-Regular.ttf +0 -0
- convoviz/assets/fonts/MartianMono-Thin.ttf +0 -0
- convoviz/assets/fonts/Montserrat-Regular.ttf +0 -0
- convoviz/assets/fonts/Mooli-Regular.ttf +0 -0
- convoviz/assets/fonts/Pacifico-Regular.ttf +0 -0
- convoviz/assets/fonts/PlayfairDisplay-Regular.ttf +0 -0
- convoviz/assets/fonts/Raleway-Regular.ttf +0 -0
- convoviz/assets/fonts/RobotoMono-Regular.ttf +0 -0
- convoviz/assets/fonts/RobotoMono-Thin.ttf +0 -0
- convoviz/assets/fonts/RobotoSlab-Regular.ttf +0 -0
- convoviz/assets/fonts/RobotoSlab-Thin.ttf +0 -0
- convoviz/assets/fonts/Ruwudu-Regular.ttf +0 -0
- convoviz/assets/fonts/Sacramento-Regular.ttf +0 -0
- convoviz/assets/fonts/SedgwickAveDisplay-Regular.ttf +0 -0
- convoviz/assets/fonts/ShadowsIntoLight-Regular.ttf +0 -0
- convoviz/assets/fonts/TitilliumWeb-Regular.ttf +0 -0
- convoviz/assets/fonts/Yellowtail-Regular.ttf +0 -0
- convoviz/assets/fonts/YsabeauOffice-Regular.ttf +0 -0
- convoviz/assets/fonts/YsabeauSC-Regular.ttf +0 -0
- convoviz/assets/fonts/YsabeauSC-Thin.ttf +0 -0
- convoviz/assets/fonts/Zeyada-Regular.ttf +0 -0
- convoviz/assets/stopwords.txt +1 -0
- convoviz/cli.py +149 -0
- convoviz/config.py +120 -0
- convoviz/exceptions.py +47 -0
- convoviz/interactive.py +264 -0
- convoviz/io/__init__.py +21 -0
- convoviz/io/assets.py +109 -0
- convoviz/io/loaders.py +191 -0
- convoviz/io/writers.py +231 -0
- convoviz/logging_config.py +69 -0
- convoviz/models/__init__.py +24 -0
- convoviz/models/collection.py +115 -0
- convoviz/models/conversation.py +158 -0
- convoviz/models/message.py +218 -0
- convoviz/models/node.py +66 -0
- convoviz/pipeline.py +184 -0
- convoviz/py.typed +0 -0
- convoviz/renderers/__init__.py +10 -0
- convoviz/renderers/markdown.py +269 -0
- convoviz/renderers/yaml.py +119 -0
- convoviz/utils.py +155 -0
- convoviz-0.4.1.dist-info/METADATA +215 -0
- convoviz-0.4.1.dist-info/RECORD +62 -0
- convoviz-0.4.1.dist-info/WHEEL +4 -0
- convoviz-0.4.1.dist-info/entry_points.txt +3 -0
convoviz/io/writers.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Writing functions for conversations and collections."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from os import utime as os_utime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from orjson import OPT_INDENT_2, dumps
|
|
9
|
+
from tqdm import tqdm
|
|
10
|
+
|
|
11
|
+
from convoviz.config import AuthorHeaders, ConversationConfig, FolderOrganization
|
|
12
|
+
from convoviz.io.assets import copy_asset, resolve_asset_path
|
|
13
|
+
from convoviz.models import Conversation, ConversationCollection
|
|
14
|
+
from convoviz.renderers import render_conversation
|
|
15
|
+
from convoviz.utils import sanitize
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# Month names for folder naming
|
|
20
|
+
_MONTH_NAMES = [
|
|
21
|
+
"January",
|
|
22
|
+
"February",
|
|
23
|
+
"March",
|
|
24
|
+
"April",
|
|
25
|
+
"May",
|
|
26
|
+
"June",
|
|
27
|
+
"July",
|
|
28
|
+
"August",
|
|
29
|
+
"September",
|
|
30
|
+
"October",
|
|
31
|
+
"November",
|
|
32
|
+
"December",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_date_folder_path(conversation: Conversation) -> Path:
|
|
37
|
+
"""Get the date-based folder path for a conversation.
|
|
38
|
+
|
|
39
|
+
Creates a nested structure: year/month
|
|
40
|
+
Example: 2024/03-March/
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
conversation: The conversation to get the path for
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Relative path for the date-based folder structure
|
|
47
|
+
"""
|
|
48
|
+
create_time = conversation.create_time
|
|
49
|
+
|
|
50
|
+
# Year folder: "2024"
|
|
51
|
+
year = str(create_time.year)
|
|
52
|
+
|
|
53
|
+
# Month folder: "03-March"
|
|
54
|
+
month_num = create_time.month
|
|
55
|
+
month_name = _MONTH_NAMES[month_num - 1]
|
|
56
|
+
month = f"{month_num:02d}-{month_name}"
|
|
57
|
+
|
|
58
|
+
return Path(year) / month
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def save_conversation(
|
|
62
|
+
conversation: Conversation,
|
|
63
|
+
filepath: Path,
|
|
64
|
+
config: ConversationConfig,
|
|
65
|
+
headers: AuthorHeaders,
|
|
66
|
+
source_path: Path | None = None,
|
|
67
|
+
) -> Path:
|
|
68
|
+
"""Save a conversation to a markdown file.
|
|
69
|
+
|
|
70
|
+
Handles filename conflicts by appending a counter. Sets the file's
|
|
71
|
+
modification time to match the conversation's update time.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
conversation: The conversation to save
|
|
75
|
+
filepath: Target file path
|
|
76
|
+
config: Conversation rendering configuration
|
|
77
|
+
headers: Author header configuration
|
|
78
|
+
source_path: Path to the source directory containing assets
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
The actual path the file was saved to (may differ if there was a conflict)
|
|
82
|
+
"""
|
|
83
|
+
# Handle filename conflicts
|
|
84
|
+
base_name = sanitize(filepath.stem)
|
|
85
|
+
final_path = filepath
|
|
86
|
+
counter = 0
|
|
87
|
+
|
|
88
|
+
while final_path.exists():
|
|
89
|
+
counter += 1
|
|
90
|
+
final_path = filepath.with_name(f"{base_name} ({counter}){filepath.suffix}")
|
|
91
|
+
|
|
92
|
+
# Define asset resolver
|
|
93
|
+
def asset_resolver(asset_id: str) -> str | None:
|
|
94
|
+
if not source_path:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
src_file = resolve_asset_path(source_path, asset_id)
|
|
98
|
+
if not src_file:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
# Copy to output directory (relative to the markdown file's directory)
|
|
102
|
+
return copy_asset(src_file, final_path.parent)
|
|
103
|
+
|
|
104
|
+
# Render and write
|
|
105
|
+
markdown = render_conversation(conversation, config, headers, asset_resolver=asset_resolver)
|
|
106
|
+
with final_path.open("w", encoding="utf-8") as f:
|
|
107
|
+
f.write(markdown)
|
|
108
|
+
logger.debug(f"Saved conversation: {final_path}")
|
|
109
|
+
|
|
110
|
+
# Set modification time
|
|
111
|
+
timestamp = conversation.update_time.timestamp()
|
|
112
|
+
os_utime(final_path, (timestamp, timestamp))
|
|
113
|
+
|
|
114
|
+
return final_path
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _generate_year_index(year_dir: Path, year: str) -> None:
|
|
118
|
+
"""Generate a _index.md file for a year folder.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
year_dir: Path to the year directory
|
|
122
|
+
year: The year string (e.g., "2024")
|
|
123
|
+
"""
|
|
124
|
+
months = sorted(
|
|
125
|
+
[d.name for d in year_dir.iterdir() if d.is_dir()],
|
|
126
|
+
key=lambda m: int(m.split("-")[0]),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
lines = [
|
|
130
|
+
f"# {year}",
|
|
131
|
+
"",
|
|
132
|
+
"## Months",
|
|
133
|
+
"",
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
for month in months:
|
|
137
|
+
month_name = month.split("-", 1)[1] if "-" in month else month
|
|
138
|
+
lines.append(f"- [{month_name}]({month}/_index.md)")
|
|
139
|
+
|
|
140
|
+
index_path = year_dir / "_index.md"
|
|
141
|
+
index_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
142
|
+
logger.debug(f"Generated year index: {index_path}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _generate_month_index(month_dir: Path, year: str, month: str) -> None:
|
|
146
|
+
"""Generate a _index.md file for a month folder.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
month_dir: Path to the month directory
|
|
150
|
+
year: The year string (e.g., "2024")
|
|
151
|
+
month: The month folder name (e.g., "03-March")
|
|
152
|
+
"""
|
|
153
|
+
month_name = month.split("-", 1)[1] if "-" in month else month
|
|
154
|
+
files = sorted([f.name for f in month_dir.glob("*.md") if f.name != "_index.md"])
|
|
155
|
+
|
|
156
|
+
lines = [
|
|
157
|
+
f"# {month_name} {year}",
|
|
158
|
+
"",
|
|
159
|
+
"## Conversations",
|
|
160
|
+
"",
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
for file in files:
|
|
164
|
+
title = file[:-3] # Remove .md extension
|
|
165
|
+
encoded_file = quote(file)
|
|
166
|
+
lines.append(f"- [{title}]({encoded_file})")
|
|
167
|
+
|
|
168
|
+
index_path = month_dir / "_index.md"
|
|
169
|
+
index_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
170
|
+
logger.debug(f"Generated month index: {index_path}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def save_collection(
|
|
174
|
+
collection: ConversationCollection,
|
|
175
|
+
directory: Path,
|
|
176
|
+
config: ConversationConfig,
|
|
177
|
+
headers: AuthorHeaders,
|
|
178
|
+
*,
|
|
179
|
+
folder_organization: FolderOrganization = FolderOrganization.FLAT,
|
|
180
|
+
progress_bar: bool = False,
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Save all conversations in a collection to markdown files.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
collection: The collection to save
|
|
186
|
+
directory: Target directory
|
|
187
|
+
config: Conversation rendering configuration
|
|
188
|
+
headers: Author header configuration
|
|
189
|
+
folder_organization: How to organize files in folders (flat or by date)
|
|
190
|
+
progress_bar: Whether to show a progress bar
|
|
191
|
+
"""
|
|
192
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
|
|
194
|
+
for conv in tqdm(
|
|
195
|
+
collection.conversations,
|
|
196
|
+
desc="Writing Markdown 📄 files",
|
|
197
|
+
disable=not progress_bar,
|
|
198
|
+
):
|
|
199
|
+
# Determine target directory based on organization setting
|
|
200
|
+
if folder_organization == FolderOrganization.DATE:
|
|
201
|
+
target_dir = directory / get_date_folder_path(conv)
|
|
202
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
else:
|
|
204
|
+
target_dir = directory
|
|
205
|
+
|
|
206
|
+
filepath = target_dir / f"{sanitize(conv.title)}.md"
|
|
207
|
+
save_conversation(conv, filepath, config, headers, source_path=collection.source_path)
|
|
208
|
+
|
|
209
|
+
# Generate index files for date organization
|
|
210
|
+
if folder_organization == FolderOrganization.DATE:
|
|
211
|
+
for year_dir in directory.iterdir():
|
|
212
|
+
if year_dir.is_dir() and year_dir.name.isdigit():
|
|
213
|
+
for month_dir in year_dir.iterdir():
|
|
214
|
+
if month_dir.is_dir():
|
|
215
|
+
_generate_month_index(month_dir, year_dir.name, month_dir.name)
|
|
216
|
+
_generate_year_index(year_dir, year_dir.name)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def save_custom_instructions(
|
|
220
|
+
collection: ConversationCollection,
|
|
221
|
+
filepath: Path,
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Save all custom instructions from a collection to a JSON file.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
collection: The collection to extract instructions from
|
|
227
|
+
filepath: Target JSON file path
|
|
228
|
+
"""
|
|
229
|
+
instructions = collection.custom_instructions
|
|
230
|
+
with filepath.open("w", encoding="utf-8") as f:
|
|
231
|
+
f.write(dumps(instructions, option=OPT_INDENT_2).decode())
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Logging configuration for convoviz."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from rich.logging import RichHandler
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def setup_logging(
|
|
11
|
+
verbosity: int = 0,
|
|
12
|
+
log_file: Path | None = None,
|
|
13
|
+
) -> Path:
|
|
14
|
+
"""Set up logging configuration.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
verbosity: Level of verbosity (0=WARNING, 1=INFO, 2=DEBUG)
|
|
18
|
+
log_file: Path to log file. If None, a temporary file is created.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Path to the log file used.
|
|
22
|
+
"""
|
|
23
|
+
# clear existing handlers
|
|
24
|
+
root_logger = logging.getLogger()
|
|
25
|
+
root_logger.handlers.clear()
|
|
26
|
+
|
|
27
|
+
# Determine log level for console
|
|
28
|
+
if verbosity >= 2:
|
|
29
|
+
console_level = logging.DEBUG
|
|
30
|
+
elif verbosity >= 1:
|
|
31
|
+
console_level = logging.INFO
|
|
32
|
+
else:
|
|
33
|
+
console_level = logging.WARNING
|
|
34
|
+
|
|
35
|
+
# Console handler (Rich)
|
|
36
|
+
console_handler = RichHandler(
|
|
37
|
+
rich_tracebacks=True,
|
|
38
|
+
markup=True,
|
|
39
|
+
show_time=False,
|
|
40
|
+
show_path=False,
|
|
41
|
+
)
|
|
42
|
+
console_handler.setLevel(console_level)
|
|
43
|
+
|
|
44
|
+
# File handler
|
|
45
|
+
if log_file is None:
|
|
46
|
+
# Create temp file if not specified
|
|
47
|
+
with tempfile.NamedTemporaryFile(prefix="convoviz_", suffix=".log", delete=False) as tf:
|
|
48
|
+
log_file = Path(tf.name)
|
|
49
|
+
|
|
50
|
+
# Ensure parent dir exists
|
|
51
|
+
if not log_file.parent.exists():
|
|
52
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
|
|
54
|
+
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
|
55
|
+
file_handler.setLevel(logging.DEBUG) # Always log DEBUG to file
|
|
56
|
+
file_formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
57
|
+
file_handler.setFormatter(file_formatter)
|
|
58
|
+
|
|
59
|
+
# Configure root logger
|
|
60
|
+
# We set root level to DEBUG so that the handlers can filter as they please
|
|
61
|
+
root_logger.setLevel(logging.DEBUG)
|
|
62
|
+
root_logger.addHandler(console_handler)
|
|
63
|
+
root_logger.addHandler(file_handler)
|
|
64
|
+
|
|
65
|
+
# Reduce noise from explicit libraries if necessary
|
|
66
|
+
logging.getLogger("matplotlib").setLevel(logging.WARNING)
|
|
67
|
+
logging.getLogger("PIL").setLevel(logging.WARNING)
|
|
68
|
+
|
|
69
|
+
return log_file
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Data models for convoviz."""
|
|
2
|
+
|
|
3
|
+
from convoviz.models.collection import ConversationCollection
|
|
4
|
+
from convoviz.models.conversation import Conversation
|
|
5
|
+
from convoviz.models.message import (
|
|
6
|
+
AuthorRole,
|
|
7
|
+
Message,
|
|
8
|
+
MessageAuthor,
|
|
9
|
+
MessageContent,
|
|
10
|
+
MessageMetadata,
|
|
11
|
+
)
|
|
12
|
+
from convoviz.models.node import Node, build_node_tree
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AuthorRole",
|
|
16
|
+
"Conversation",
|
|
17
|
+
"ConversationCollection",
|
|
18
|
+
"Message",
|
|
19
|
+
"MessageAuthor",
|
|
20
|
+
"MessageContent",
|
|
21
|
+
"MessageMetadata",
|
|
22
|
+
"Node",
|
|
23
|
+
"build_node_tree",
|
|
24
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""ConversationCollection model - manages a set of conversations.
|
|
2
|
+
|
|
3
|
+
This is a pure data model - I/O and visualization logic are in separate modules.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from convoviz.models.conversation import Conversation
|
|
13
|
+
from convoviz.models.message import AuthorRole
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConversationCollection(BaseModel):
|
|
17
|
+
"""A collection of ChatGPT conversations.
|
|
18
|
+
|
|
19
|
+
Provides grouping and aggregation operations over conversations.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
conversations: list[Conversation] = Field(default_factory=list)
|
|
23
|
+
source_path: Path | None = None
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def index(self) -> dict[str, Conversation]:
|
|
27
|
+
"""Get conversations indexed by conversation_id."""
|
|
28
|
+
return {conv.conversation_id: conv for conv in self.conversations}
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def last_updated(self) -> datetime:
|
|
32
|
+
"""Get the most recent update time across all conversations."""
|
|
33
|
+
if not self.conversations:
|
|
34
|
+
return datetime.min
|
|
35
|
+
return max(conv.update_time for conv in self.conversations)
|
|
36
|
+
|
|
37
|
+
def update(self, other: "ConversationCollection") -> None:
|
|
38
|
+
"""Merge another collection into this one.
|
|
39
|
+
|
|
40
|
+
Merges per-conversation, keeping the newest version when IDs collide.
|
|
41
|
+
|
|
42
|
+
Note: We intentionally do *not* gate on ``other.last_updated`` because
|
|
43
|
+
"new" conversations can still have older timestamps than the most recent
|
|
44
|
+
conversation in this collection (e.g. bookmarklet downloads).
|
|
45
|
+
"""
|
|
46
|
+
merged: dict[str, Conversation] = dict(self.index)
|
|
47
|
+
|
|
48
|
+
for conv_id, incoming in other.index.items():
|
|
49
|
+
existing = merged.get(conv_id)
|
|
50
|
+
if existing is None or incoming.update_time > existing.update_time:
|
|
51
|
+
merged[conv_id] = incoming
|
|
52
|
+
|
|
53
|
+
self.conversations = list(merged.values())
|
|
54
|
+
|
|
55
|
+
def add(self, conversation: Conversation) -> None:
|
|
56
|
+
"""Add a conversation to the collection."""
|
|
57
|
+
self.conversations.append(conversation)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def custom_instructions(self) -> list[dict[str, Any]]:
|
|
61
|
+
"""Get all custom instructions from all conversations."""
|
|
62
|
+
instructions: list[dict[str, Any]] = []
|
|
63
|
+
for conv in self.conversations:
|
|
64
|
+
if not conv.custom_instructions:
|
|
65
|
+
continue
|
|
66
|
+
instructions.append(
|
|
67
|
+
{
|
|
68
|
+
"chat_title": conv.title,
|
|
69
|
+
"chat_link": conv.url,
|
|
70
|
+
"time": conv.create_time,
|
|
71
|
+
"custom_instructions": conv.custom_instructions,
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
return instructions
|
|
75
|
+
|
|
76
|
+
def timestamps(self, *authors: AuthorRole) -> list[float]:
|
|
77
|
+
"""Get all message timestamps from specified authors."""
|
|
78
|
+
result: list[float] = []
|
|
79
|
+
for conv in self.conversations:
|
|
80
|
+
result.extend(conv.timestamps(*authors))
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
def plaintext(self, *authors: AuthorRole) -> str:
|
|
84
|
+
"""Get concatenated plain text from all conversations."""
|
|
85
|
+
return "\n".join(conv.plaintext(*authors) for conv in self.conversations)
|
|
86
|
+
|
|
87
|
+
def group_by_week(self) -> dict[datetime, "ConversationCollection"]:
|
|
88
|
+
"""Group conversations by the week they were created."""
|
|
89
|
+
groups: dict[datetime, ConversationCollection] = {}
|
|
90
|
+
for conv in self.conversations:
|
|
91
|
+
week_start = conv.week_start
|
|
92
|
+
if week_start not in groups:
|
|
93
|
+
groups[week_start] = ConversationCollection()
|
|
94
|
+
groups[week_start].add(conv)
|
|
95
|
+
return groups
|
|
96
|
+
|
|
97
|
+
def group_by_month(self) -> dict[datetime, "ConversationCollection"]:
|
|
98
|
+
"""Group conversations by the month they were created."""
|
|
99
|
+
groups: dict[datetime, ConversationCollection] = {}
|
|
100
|
+
for conv in self.conversations:
|
|
101
|
+
month_start = conv.month_start
|
|
102
|
+
if month_start not in groups:
|
|
103
|
+
groups[month_start] = ConversationCollection()
|
|
104
|
+
groups[month_start].add(conv)
|
|
105
|
+
return groups
|
|
106
|
+
|
|
107
|
+
def group_by_year(self) -> dict[datetime, "ConversationCollection"]:
|
|
108
|
+
"""Group conversations by the year they were created."""
|
|
109
|
+
groups: dict[datetime, ConversationCollection] = {}
|
|
110
|
+
for conv in self.conversations:
|
|
111
|
+
year_start = conv.year_start
|
|
112
|
+
if year_start not in groups:
|
|
113
|
+
groups[year_start] = ConversationCollection()
|
|
114
|
+
groups[year_start].add(conv)
|
|
115
|
+
return groups
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Conversation model - pure data class.
|
|
2
|
+
|
|
3
|
+
Object path: conversations.json -> conversation (one of the list items)
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from convoviz.models.message import AuthorRole
|
|
12
|
+
from convoviz.models.node import Node, build_node_tree
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Conversation(BaseModel):
|
|
16
|
+
"""A single ChatGPT conversation.
|
|
17
|
+
|
|
18
|
+
This is a pure data model - rendering and I/O logic are in separate modules.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
title: str
|
|
22
|
+
create_time: datetime
|
|
23
|
+
update_time: datetime
|
|
24
|
+
mapping: dict[str, Node]
|
|
25
|
+
moderation_results: list[Any] = Field(default_factory=list)
|
|
26
|
+
current_node: str
|
|
27
|
+
plugin_ids: list[str] | None = None
|
|
28
|
+
conversation_id: str
|
|
29
|
+
conversation_template_id: str | None = None
|
|
30
|
+
id: str | None = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def node_mapping(self) -> dict[str, Node]:
|
|
34
|
+
"""Get the connected node tree."""
|
|
35
|
+
return build_node_tree(self.mapping)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def all_message_nodes(self) -> list[Node]:
|
|
39
|
+
"""Get all nodes that have messages (including hidden/internal ones)."""
|
|
40
|
+
return [node for node in self.node_mapping.values() if node.has_message]
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def visible_message_nodes(self) -> list[Node]:
|
|
44
|
+
"""Get all nodes that have *visible* (non-hidden) messages."""
|
|
45
|
+
return [
|
|
46
|
+
node
|
|
47
|
+
for node in self.node_mapping.values()
|
|
48
|
+
if node.has_message and node.message is not None and not node.message.is_hidden
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
def nodes_by_author(self, *authors: AuthorRole, include_hidden: bool = False) -> list[Node]:
|
|
52
|
+
"""Get nodes with messages from specified authors.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
*authors: Author roles to filter by. Defaults to ("user",) if empty.
|
|
56
|
+
include_hidden: Whether to include hidden/internal messages.
|
|
57
|
+
"""
|
|
58
|
+
if not authors:
|
|
59
|
+
authors = ("user",)
|
|
60
|
+
nodes = self.all_message_nodes if include_hidden else self.visible_message_nodes
|
|
61
|
+
return [node for node in nodes if node.message and node.message.author.role in authors]
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def leaf_count(self) -> int:
|
|
65
|
+
"""Count the number of leaf nodes (conversation endpoints)."""
|
|
66
|
+
return sum(1 for node in self.all_message_nodes if node.is_leaf)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def url(self) -> str:
|
|
70
|
+
"""Get the ChatGPT URL for this conversation."""
|
|
71
|
+
return f"https://chat.openai.com/c/{self.conversation_id}"
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def content_types(self) -> list[str]:
|
|
75
|
+
"""Get all unique content types in the conversation (excluding hidden messages)."""
|
|
76
|
+
return list(
|
|
77
|
+
{
|
|
78
|
+
node.message.content.content_type
|
|
79
|
+
for node in self.visible_message_nodes
|
|
80
|
+
if node.message
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def message_count(self, *authors: AuthorRole) -> int:
|
|
85
|
+
"""Count messages from specified authors."""
|
|
86
|
+
return len(self.nodes_by_author(*authors))
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def model(self) -> str | None:
|
|
90
|
+
"""Get the ChatGPT model used for this conversation."""
|
|
91
|
+
assistant_nodes = self.nodes_by_author("assistant")
|
|
92
|
+
if not assistant_nodes:
|
|
93
|
+
return None
|
|
94
|
+
message = assistant_nodes[0].message
|
|
95
|
+
return message.metadata.model_slug if message else None
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def plugins(self) -> list[str]:
|
|
99
|
+
"""Get all plugins used in this conversation."""
|
|
100
|
+
return list(
|
|
101
|
+
{
|
|
102
|
+
node.message.metadata.invoked_plugin["namespace"]
|
|
103
|
+
for node in self.nodes_by_author("tool")
|
|
104
|
+
if node.message and node.message.metadata.invoked_plugin
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def custom_instructions(self) -> dict[str, str]:
|
|
110
|
+
"""Get custom instructions used for this conversation."""
|
|
111
|
+
system_nodes = self.nodes_by_author("system")
|
|
112
|
+
for node in system_nodes:
|
|
113
|
+
context_message = node.message
|
|
114
|
+
if context_message and context_message.metadata.is_user_system_message:
|
|
115
|
+
return context_message.metadata.user_context_message_data or {}
|
|
116
|
+
return {}
|
|
117
|
+
|
|
118
|
+
def timestamps(self, *authors: AuthorRole) -> list[float]:
|
|
119
|
+
"""Get message timestamps from specified authors.
|
|
120
|
+
|
|
121
|
+
Useful for generating time-based visualizations.
|
|
122
|
+
"""
|
|
123
|
+
if not authors:
|
|
124
|
+
authors = ("user",)
|
|
125
|
+
return [
|
|
126
|
+
node.message.create_time.timestamp()
|
|
127
|
+
for node in self.nodes_by_author(*authors)
|
|
128
|
+
if node.message and node.message.create_time
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
def plaintext(self, *authors: AuthorRole) -> str:
|
|
132
|
+
"""Get concatenated plain text from specified authors.
|
|
133
|
+
|
|
134
|
+
Useful for word cloud generation.
|
|
135
|
+
"""
|
|
136
|
+
if not authors:
|
|
137
|
+
authors = ("user",)
|
|
138
|
+
return "\n".join(
|
|
139
|
+
node.message.text
|
|
140
|
+
for node in self.nodes_by_author(*authors)
|
|
141
|
+
if node.message and node.message.has_content
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def week_start(self) -> datetime:
|
|
146
|
+
"""Get the Monday of the week this conversation was created."""
|
|
147
|
+
start_of_week = self.create_time - timedelta(days=self.create_time.weekday())
|
|
148
|
+
return start_of_week.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def month_start(self) -> datetime:
|
|
152
|
+
"""Get the first day of the month this conversation was created."""
|
|
153
|
+
return self.create_time.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def year_start(self) -> datetime:
|
|
157
|
+
"""Get January 1st of the year this conversation was created."""
|
|
158
|
+
return self.create_time.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|