convoviz 0.2.3__tar.gz → 0.2.5__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.
- {convoviz-0.2.3 → convoviz-0.2.5}/PKG-INFO +30 -5
- {convoviz-0.2.3 → convoviz-0.2.5}/README.md +29 -4
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/analysis/graphs.py +98 -40
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/analysis/wordcloud.py +1 -1
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/config.py +3 -1
- convoviz-0.2.5/convoviz/interactive.py +247 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/io/loaders.py +28 -5
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/models/collection.py +12 -6
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/models/conversation.py +24 -15
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/models/message.py +42 -4
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/pipeline.py +31 -8
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/renderers/markdown.py +57 -21
- convoviz-0.2.5/convoviz/renderers/yaml.py +119 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/pyproject.toml +2 -3
- convoviz-0.2.3/convoviz/interactive.py +0 -188
- convoviz-0.2.3/convoviz/renderers/yaml.py +0 -42
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/__init__.py +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/__main__.py +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/analysis/__init__.py +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/colormaps.txt +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/AmaticSC-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/ArchitectsDaughter-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/BebasNeue-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Borel-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Courgette-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/CroissantOne-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Handjet-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/IndieFlower-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Kalam-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Lobster-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/MartianMono-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/MartianMono-Thin.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Montserrat-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Mooli-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Pacifico-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/PlayfairDisplay-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Raleway-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/RobotoMono-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/RobotoMono-Thin.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/RobotoSlab-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/RobotoSlab-Thin.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Ruwudu-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Sacramento-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/SedgwickAveDisplay-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/ShadowsIntoLight-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/TitilliumWeb-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Yellowtail-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/YsabeauOffice-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/YsabeauSC-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/YsabeauSC-Thin.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/fonts/Zeyada-Regular.ttf +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/assets/stopwords.txt +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/cli.py +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/exceptions.py +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/io/__init__.py +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/io/assets.py +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/io/writers.py +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/models/__init__.py +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/models/node.py +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/py.typed +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/renderers/__init__.py +0 -0
- {convoviz-0.2.3 → convoviz-0.2.5}/convoviz/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: convoviz
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.5
|
|
4
4
|
Summary: Get analytics and visualizations on your ChatGPT data!
|
|
5
5
|
Keywords: markdown,chatgpt,openai,visualization,analytics,json,export,data-analysis,obsidian
|
|
6
6
|
Author: Mohamed Cheikh Sidiya
|
|
@@ -24,7 +24,7 @@ Requires-Python: >=3.12
|
|
|
24
24
|
Project-URL: Repository, https://github.com/mohamed-chs/chatgpt-history-export-to-md
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
26
26
|
|
|
27
|
-
# Convoviz 📊: Visualize your entire ChatGPT data
|
|
27
|
+
# Convoviz 📊: Visualize your entire ChatGPT data
|
|
28
28
|
|
|
29
29
|
Convert your ChatGPT history into well-formatted Markdown files. Additionally, visualize your data with word clouds 🔡☁️, view your prompt history graphs 📈, and access all your custom instructions 🤖 in a single location.
|
|
30
30
|
|
|
@@ -68,7 +68,7 @@ or pipx:
|
|
|
68
68
|
pipx install convoviz
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
### 3. Run the
|
|
71
|
+
### 3. Run the tool 🏃♂️
|
|
72
72
|
|
|
73
73
|
Simply run the command and follow the prompts:
|
|
74
74
|
|
|
@@ -81,9 +81,18 @@ convoviz
|
|
|
81
81
|
You can provide arguments directly to skip the prompts:
|
|
82
82
|
|
|
83
83
|
```bash
|
|
84
|
-
convoviz --
|
|
84
|
+
convoviz --input path/to/your/export.zip --output path/to/output/folder
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
+
Inputs can be any of:
|
|
88
|
+
- A ChatGPT export ZIP (downloaded from OpenAI)
|
|
89
|
+
- An extracted export directory containing `conversations.json`
|
|
90
|
+
- A `conversations.json` file directly
|
|
91
|
+
|
|
92
|
+
Notes:
|
|
93
|
+
- `--zip` / `-z` is kept as an alias for `--input` for convenience.
|
|
94
|
+
- You can force non-interactive mode with `--no-interactive`.
|
|
95
|
+
|
|
87
96
|
For more options, run:
|
|
88
97
|
|
|
89
98
|
```bash
|
|
@@ -118,4 +127,20 @@ It was also a great opportunity to learn more about Python and type annotations.
|
|
|
118
127
|
|
|
119
128
|
It should(?) also work as library, so you can import and use the models and functions. I need to add more documentation for that tho. Feel free to reach out if you need help.
|
|
120
129
|
|
|
121
|
-
|
|
130
|
+
### Offline / reproducible runs
|
|
131
|
+
|
|
132
|
+
Convoviz uses NLTK stopwords for word clouds. If you’re offline and NLTK data isn’t already installed, pre-download it once:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
python -c "import nltk; nltk.download('stopwords')"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
If you’re using `uv` without a global install, you can run:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
uv run python -c "import nltk; nltk.download('stopwords')"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Bookmarklet
|
|
145
|
+
|
|
146
|
+
There’s also a JavaScript bookmarklet flow under `js/` (experimental) for exporting additional conversation data outside the official ZIP export.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Convoviz 📊: Visualize your entire ChatGPT data
|
|
1
|
+
# Convoviz 📊: Visualize your entire ChatGPT data
|
|
2
2
|
|
|
3
3
|
Convert your ChatGPT history into well-formatted Markdown files. Additionally, visualize your data with word clouds 🔡☁️, view your prompt history graphs 📈, and access all your custom instructions 🤖 in a single location.
|
|
4
4
|
|
|
@@ -42,7 +42,7 @@ or pipx:
|
|
|
42
42
|
pipx install convoviz
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
### 3. Run the
|
|
45
|
+
### 3. Run the tool 🏃♂️
|
|
46
46
|
|
|
47
47
|
Simply run the command and follow the prompts:
|
|
48
48
|
|
|
@@ -55,9 +55,18 @@ convoviz
|
|
|
55
55
|
You can provide arguments directly to skip the prompts:
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
|
-
convoviz --
|
|
58
|
+
convoviz --input path/to/your/export.zip --output path/to/output/folder
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
Inputs can be any of:
|
|
62
|
+
- A ChatGPT export ZIP (downloaded from OpenAI)
|
|
63
|
+
- An extracted export directory containing `conversations.json`
|
|
64
|
+
- A `conversations.json` file directly
|
|
65
|
+
|
|
66
|
+
Notes:
|
|
67
|
+
- `--zip` / `-z` is kept as an alias for `--input` for convenience.
|
|
68
|
+
- You can force non-interactive mode with `--no-interactive`.
|
|
69
|
+
|
|
61
70
|
For more options, run:
|
|
62
71
|
|
|
63
72
|
```bash
|
|
@@ -92,4 +101,20 @@ It was also a great opportunity to learn more about Python and type annotations.
|
|
|
92
101
|
|
|
93
102
|
It should(?) also work as library, so you can import and use the models and functions. I need to add more documentation for that tho. Feel free to reach out if you need help.
|
|
94
103
|
|
|
95
|
-
|
|
104
|
+
### Offline / reproducible runs
|
|
105
|
+
|
|
106
|
+
Convoviz uses NLTK stopwords for word clouds. If you’re offline and NLTK data isn’t already installed, pre-download it once:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
python -c "import nltk; nltk.download('stopwords')"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
If you’re using `uv` without a global install, you can run:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
uv run python -c "import nltk; nltk.download('stopwords')"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Bookmarklet
|
|
119
|
+
|
|
120
|
+
There’s also a JavaScript bookmarklet flow under `js/` (experimental) for exporting additional conversation data outside the official ZIP export.
|
|
@@ -4,7 +4,9 @@ from collections import defaultdict
|
|
|
4
4
|
from datetime import UTC, datetime
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
+
import matplotlib.dates as mdates
|
|
7
8
|
import matplotlib.font_manager as fm
|
|
9
|
+
from matplotlib.axes import Axes
|
|
8
10
|
from matplotlib.figure import Figure
|
|
9
11
|
from tqdm import tqdm
|
|
10
12
|
|
|
@@ -23,10 +25,10 @@ WEEKDAYS = [
|
|
|
23
25
|
]
|
|
24
26
|
|
|
25
27
|
|
|
26
|
-
def _setup_figure(config: GraphConfig) -> tuple[Figure, fm.FontProperties]:
|
|
28
|
+
def _setup_figure(config: GraphConfig) -> tuple[Figure, Axes, fm.FontProperties]:
|
|
27
29
|
"""Internal helper to setup a figure with common styling."""
|
|
28
|
-
fig = Figure(figsize=config.figsize, dpi=
|
|
29
|
-
ax = fig.add_subplot()
|
|
30
|
+
fig = Figure(figsize=config.figsize, dpi=config.dpi)
|
|
31
|
+
ax: Axes = fig.add_subplot()
|
|
30
32
|
|
|
31
33
|
# Load custom font if possible
|
|
32
34
|
font_path = get_asset_path(f"fonts/{config.font_name}")
|
|
@@ -35,12 +37,27 @@ def _setup_figure(config: GraphConfig) -> tuple[Figure, fm.FontProperties]:
|
|
|
35
37
|
)
|
|
36
38
|
|
|
37
39
|
# Styling
|
|
40
|
+
fig.set_facecolor("white")
|
|
41
|
+
ax.set_facecolor("white")
|
|
38
42
|
ax.spines["top"].set_visible(False)
|
|
39
43
|
ax.spines["right"].set_visible(False)
|
|
40
44
|
if config.grid:
|
|
41
45
|
ax.grid(axis="y", linestyle="--", alpha=0.7)
|
|
46
|
+
ax.set_axisbelow(True)
|
|
42
47
|
|
|
43
|
-
return fig, font_prop
|
|
48
|
+
return fig, ax, font_prop
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ts_to_dt(ts: float, config: GraphConfig) -> datetime:
|
|
52
|
+
"""Convert epoch timestamps into aware datetimes based on config."""
|
|
53
|
+
dt_utc = datetime.fromtimestamp(ts, UTC)
|
|
54
|
+
if config.timezone == "utc":
|
|
55
|
+
return dt_utc
|
|
56
|
+
return dt_utc.astimezone()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _tz_label(config: GraphConfig) -> str:
|
|
60
|
+
return "UTC" if config.timezone == "utc" else "Local"
|
|
44
61
|
|
|
45
62
|
|
|
46
63
|
def generate_week_barplot(
|
|
@@ -59,37 +76,37 @@ def generate_week_barplot(
|
|
|
59
76
|
Matplotlib Figure object
|
|
60
77
|
"""
|
|
61
78
|
cfg = config or get_default_config().graph
|
|
62
|
-
dates = [
|
|
79
|
+
dates = [_ts_to_dt(ts, cfg) for ts in timestamps]
|
|
63
80
|
|
|
64
81
|
weekday_counts: defaultdict[str, int] = defaultdict(int)
|
|
65
82
|
for date in dates:
|
|
66
83
|
weekday_counts[WEEKDAYS[date.weekday()]] += 1
|
|
67
84
|
|
|
68
|
-
x = WEEKDAYS
|
|
85
|
+
x = list(range(len(WEEKDAYS)))
|
|
69
86
|
y = [weekday_counts[day] for day in WEEKDAYS]
|
|
70
87
|
|
|
71
|
-
fig, font_prop = _setup_figure(cfg)
|
|
72
|
-
ax = fig.gca()
|
|
88
|
+
fig, ax, font_prop = _setup_figure(cfg)
|
|
73
89
|
|
|
74
|
-
bars = ax.bar(x, y, color=cfg.color, alpha=0.
|
|
90
|
+
bars = ax.bar(x, y, color=cfg.color, alpha=0.85)
|
|
75
91
|
|
|
76
92
|
if cfg.show_counts:
|
|
77
93
|
for bar in bars:
|
|
78
94
|
height = bar.get_height()
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
95
|
+
if height > 0:
|
|
96
|
+
ax.text(
|
|
97
|
+
bar.get_x() + bar.get_width() / 2.0,
|
|
98
|
+
height,
|
|
99
|
+
f"{int(height)}",
|
|
100
|
+
ha="center",
|
|
101
|
+
va="bottom",
|
|
102
|
+
fontproperties=font_prop,
|
|
103
|
+
)
|
|
87
104
|
|
|
88
105
|
ax.set_xlabel("Weekday", fontproperties=font_prop)
|
|
89
|
-
ax.set_ylabel("Prompt Count", fontproperties=font_prop)
|
|
106
|
+
ax.set_ylabel("User Prompt Count", fontproperties=font_prop)
|
|
90
107
|
ax.set_title(title, fontproperties=font_prop, fontsize=16, pad=20)
|
|
91
|
-
ax.set_xticks(
|
|
92
|
-
ax.set_xticklabels(
|
|
108
|
+
ax.set_xticks(x)
|
|
109
|
+
ax.set_xticklabels(WEEKDAYS, rotation=45, fontproperties=font_prop)
|
|
93
110
|
|
|
94
111
|
for label in ax.get_yticklabels():
|
|
95
112
|
label.set_fontproperties(font_prop)
|
|
@@ -114,7 +131,7 @@ def generate_hour_barplot(
|
|
|
114
131
|
Matplotlib Figure object
|
|
115
132
|
"""
|
|
116
133
|
cfg = config or get_default_config().graph
|
|
117
|
-
dates = [
|
|
134
|
+
dates = [_ts_to_dt(ts, cfg) for ts in timestamps]
|
|
118
135
|
|
|
119
136
|
hour_counts: dict[int, int] = dict.fromkeys(range(24), 0)
|
|
120
137
|
for date in dates:
|
|
@@ -123,8 +140,7 @@ def generate_hour_barplot(
|
|
|
123
140
|
x = [f"{i:02d}:00" for i in range(24)]
|
|
124
141
|
y = [hour_counts[i] for i in range(24)]
|
|
125
142
|
|
|
126
|
-
fig, font_prop = _setup_figure(cfg)
|
|
127
|
-
ax = fig.gca()
|
|
143
|
+
fig, ax, font_prop = _setup_figure(cfg)
|
|
128
144
|
|
|
129
145
|
bars = ax.bar(range(24), y, color=cfg.color, alpha=0.8)
|
|
130
146
|
|
|
@@ -142,8 +158,8 @@ def generate_hour_barplot(
|
|
|
142
158
|
fontsize=8,
|
|
143
159
|
)
|
|
144
160
|
|
|
145
|
-
ax.set_xlabel("Hour of Day (
|
|
146
|
-
ax.set_ylabel("Prompt Count", fontproperties=font_prop)
|
|
161
|
+
ax.set_xlabel(f"Hour of Day ({_tz_label(cfg)})", fontproperties=font_prop)
|
|
162
|
+
ax.set_ylabel("User Prompt Count", fontproperties=font_prop)
|
|
147
163
|
ax.set_title(f"{title} - Hourly Distribution", fontproperties=font_prop, fontsize=16, pad=20)
|
|
148
164
|
ax.set_xticks(range(24))
|
|
149
165
|
ax.set_xticklabels(x, rotation=90, fontproperties=font_prop)
|
|
@@ -180,8 +196,7 @@ def generate_model_piechart(
|
|
|
180
196
|
total = sum(model_counts.values())
|
|
181
197
|
if total == 0:
|
|
182
198
|
# Return empty figure or figure with "No Data"
|
|
183
|
-
fig, font_prop = _setup_figure(cfg)
|
|
184
|
-
ax = fig.gca()
|
|
199
|
+
fig, ax, font_prop = _setup_figure(cfg)
|
|
185
200
|
ax.text(0.5, 0.5, "No Data", ha="center", va="center", fontproperties=font_prop)
|
|
186
201
|
return fig
|
|
187
202
|
|
|
@@ -204,8 +219,7 @@ def generate_model_piechart(
|
|
|
204
219
|
labels = [item[0] for item in sorted_items]
|
|
205
220
|
sizes = [item[1] for item in sorted_items]
|
|
206
221
|
|
|
207
|
-
fig, font_prop = _setup_figure(cfg)
|
|
208
|
-
ax = fig.gca()
|
|
222
|
+
fig, ax, font_prop = _setup_figure(cfg)
|
|
209
223
|
|
|
210
224
|
colors = [
|
|
211
225
|
"#4A90E2",
|
|
@@ -250,17 +264,16 @@ def generate_length_histogram(
|
|
|
250
264
|
cfg = config or get_default_config().graph
|
|
251
265
|
lengths = [conv.message_count("user") for conv in collection.conversations]
|
|
252
266
|
|
|
253
|
-
fig, font_prop = _setup_figure(cfg)
|
|
254
|
-
ax = fig.gca()
|
|
267
|
+
fig, ax, font_prop = _setup_figure(cfg)
|
|
255
268
|
|
|
256
269
|
if not lengths:
|
|
257
270
|
ax.text(0.5, 0.5, "No Data", ha="center", va="center", fontproperties=font_prop)
|
|
258
271
|
return fig
|
|
259
272
|
|
|
260
|
-
import numpy as np
|
|
261
|
-
|
|
262
273
|
# Cap at 95th percentile to focus on most conversations
|
|
263
|
-
|
|
274
|
+
sorted_lengths = sorted(lengths)
|
|
275
|
+
idx = int(0.95 * (len(sorted_lengths) - 1))
|
|
276
|
+
cap = int(sorted_lengths[idx])
|
|
264
277
|
cap = max(cap, 5) # Ensure at least some range
|
|
265
278
|
|
|
266
279
|
# Filter lengths for the histogram plot, but keep the data correct
|
|
@@ -306,10 +319,10 @@ def generate_monthly_activity_barplot(
|
|
|
306
319
|
x = [m.strftime("%b '%y") for m in sorted_months]
|
|
307
320
|
y = [len(month_groups[m].timestamps("user")) for m in sorted_months]
|
|
308
321
|
|
|
309
|
-
fig, font_prop = _setup_figure(cfg)
|
|
310
|
-
ax = fig.gca()
|
|
322
|
+
fig, ax, font_prop = _setup_figure(cfg)
|
|
311
323
|
|
|
312
|
-
|
|
324
|
+
positions = list(range(len(x)))
|
|
325
|
+
bars = ax.bar(positions, y, color=cfg.color, alpha=0.85)
|
|
313
326
|
|
|
314
327
|
if cfg.show_counts:
|
|
315
328
|
for bar in bars:
|
|
@@ -326,10 +339,12 @@ def generate_monthly_activity_barplot(
|
|
|
326
339
|
)
|
|
327
340
|
|
|
328
341
|
ax.set_xlabel("Month", fontproperties=font_prop)
|
|
329
|
-
ax.set_ylabel("
|
|
342
|
+
ax.set_ylabel("User Prompt Count", fontproperties=font_prop)
|
|
330
343
|
ax.set_title("Monthly Activity History", fontproperties=font_prop, fontsize=16, pad=20)
|
|
331
|
-
|
|
332
|
-
|
|
344
|
+
tick_step = max(1, len(positions) // 12) # show ~12 labels max
|
|
345
|
+
shown = positions[::tick_step] if positions else []
|
|
346
|
+
ax.set_xticks(shown)
|
|
347
|
+
ax.set_xticklabels([x[i] for i in shown], rotation=45, fontproperties=font_prop)
|
|
333
348
|
|
|
334
349
|
for label in ax.get_yticklabels():
|
|
335
350
|
label.set_fontproperties(font_prop)
|
|
@@ -338,6 +353,45 @@ def generate_monthly_activity_barplot(
|
|
|
338
353
|
return fig
|
|
339
354
|
|
|
340
355
|
|
|
356
|
+
def generate_daily_activity_lineplot(
|
|
357
|
+
collection: ConversationCollection,
|
|
358
|
+
config: GraphConfig | None = None,
|
|
359
|
+
) -> Figure:
|
|
360
|
+
"""Create a line chart showing user prompt count per day."""
|
|
361
|
+
cfg = config or get_default_config().graph
|
|
362
|
+
timestamps = collection.timestamps("user")
|
|
363
|
+
|
|
364
|
+
fig, ax, font_prop = _setup_figure(cfg)
|
|
365
|
+
if not timestamps:
|
|
366
|
+
ax.text(0.5, 0.5, "No Data", ha="center", va="center", fontproperties=font_prop)
|
|
367
|
+
return fig
|
|
368
|
+
|
|
369
|
+
counts: defaultdict[datetime, int] = defaultdict(int)
|
|
370
|
+
for ts in timestamps:
|
|
371
|
+
dt = _ts_to_dt(ts, cfg)
|
|
372
|
+
day = dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
373
|
+
counts[day] += 1
|
|
374
|
+
|
|
375
|
+
days = sorted(counts.keys())
|
|
376
|
+
values = [counts[d] for d in days]
|
|
377
|
+
|
|
378
|
+
x = mdates.date2num(days)
|
|
379
|
+
ax.plot(x, values, color=cfg.color, linewidth=2.0)
|
|
380
|
+
ax.fill_between(x, values, color=cfg.color, alpha=0.15)
|
|
381
|
+
locator = mdates.AutoDateLocator()
|
|
382
|
+
ax.xaxis.set_major_locator(locator)
|
|
383
|
+
ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
|
|
384
|
+
ax.set_title("Daily Activity History", fontproperties=font_prop, fontsize=16, pad=20)
|
|
385
|
+
ax.set_xlabel(f"Day ({_tz_label(cfg)})", fontproperties=font_prop)
|
|
386
|
+
ax.set_ylabel("User Prompt Count", fontproperties=font_prop)
|
|
387
|
+
|
|
388
|
+
for label in ax.get_xticklabels() + ax.get_yticklabels():
|
|
389
|
+
label.set_fontproperties(font_prop)
|
|
390
|
+
|
|
391
|
+
fig.tight_layout()
|
|
392
|
+
return fig
|
|
393
|
+
|
|
394
|
+
|
|
341
395
|
def generate_summary_graphs(
|
|
342
396
|
collection: ConversationCollection,
|
|
343
397
|
output_dir: Path,
|
|
@@ -368,6 +422,10 @@ def generate_summary_graphs(
|
|
|
368
422
|
fig_activity = generate_monthly_activity_barplot(collection, config)
|
|
369
423
|
fig_activity.savefig(summary_dir / "monthly_activity.png")
|
|
370
424
|
|
|
425
|
+
# Daily activity
|
|
426
|
+
fig_daily = generate_daily_activity_lineplot(collection, config)
|
|
427
|
+
fig_daily.savefig(summary_dir / "daily_activity.png")
|
|
428
|
+
|
|
371
429
|
|
|
372
430
|
def generate_graphs(
|
|
373
431
|
collection: ConversationCollection,
|
|
@@ -62,7 +62,7 @@ def load_nltk_stopwords() -> frozenset[str]:
|
|
|
62
62
|
return frozenset(words)
|
|
63
63
|
|
|
64
64
|
|
|
65
|
-
def parse_custom_stopwords(stopwords_str: str) -> set[str]:
|
|
65
|
+
def parse_custom_stopwords(stopwords_str: str | None) -> set[str]:
|
|
66
66
|
"""Parse a comma-separated string of custom stopwords.
|
|
67
67
|
|
|
68
68
|
Args:
|
|
@@ -19,7 +19,7 @@ class MarkdownConfig(BaseModel):
|
|
|
19
19
|
"""Configuration for markdown output."""
|
|
20
20
|
|
|
21
21
|
latex_delimiters: Literal["default", "dollars"] = "default"
|
|
22
|
-
flavor: Literal["obsidian", "standard"] = "
|
|
22
|
+
flavor: Literal["obsidian", "standard"] = "standard"
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
class YAMLConfig(BaseModel):
|
|
@@ -72,6 +72,8 @@ class GraphConfig(BaseModel):
|
|
|
72
72
|
show_counts: bool = True
|
|
73
73
|
font_name: str = "Montserrat-Regular.ttf"
|
|
74
74
|
figsize: tuple[int, int] = (10, 6)
|
|
75
|
+
dpi: int = 300
|
|
76
|
+
timezone: Literal["utc", "local"] = "local"
|
|
75
77
|
|
|
76
78
|
|
|
77
79
|
class ConvovizConfig(BaseModel):
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Interactive configuration prompts using questionary."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Literal, Protocol, cast
|
|
5
|
+
|
|
6
|
+
from questionary import Choice, Style, checkbox, select
|
|
7
|
+
from questionary import path as qst_path
|
|
8
|
+
from questionary import text as qst_text
|
|
9
|
+
|
|
10
|
+
from convoviz.config import ConvovizConfig, get_default_config
|
|
11
|
+
from convoviz.io.loaders import find_latest_zip, validate_zip
|
|
12
|
+
from convoviz.utils import colormaps, default_font_path, font_names, font_path, validate_header
|
|
13
|
+
|
|
14
|
+
CUSTOM_STYLE = Style(
|
|
15
|
+
[
|
|
16
|
+
("qmark", "fg:#34eb9b bold"),
|
|
17
|
+
("question", "bold fg:#e0e0e0"),
|
|
18
|
+
("answer", "fg:#34ebeb bold"),
|
|
19
|
+
("pointer", "fg:#e834eb bold"),
|
|
20
|
+
("highlighted", "fg:#349ceb bold"),
|
|
21
|
+
("selected", "fg:#34ebeb"),
|
|
22
|
+
("separator", "fg:#eb3434"),
|
|
23
|
+
("instruction", "fg:#eb9434"),
|
|
24
|
+
("text", "fg:#b2eb34"),
|
|
25
|
+
("disabled", "fg:#858585 italic"),
|
|
26
|
+
]
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
class _QuestionaryPrompt[T](Protocol):
|
|
30
|
+
def ask(self) -> T | None: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _ask_or_cancel[T](prompt: _QuestionaryPrompt[T]) -> T:
|
|
34
|
+
"""Ask a questionary prompt; treat Ctrl+C/Ctrl+D as cancelling the run.
|
|
35
|
+
|
|
36
|
+
questionary's `.ask()` returns `None` on cancellation (Ctrl+C / Ctrl+D). We
|
|
37
|
+
convert that to `KeyboardInterrupt` so callers can abort the whole
|
|
38
|
+
interactive session with a single Ctrl+C.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
result = prompt.ask()
|
|
42
|
+
if result is None:
|
|
43
|
+
raise KeyboardInterrupt
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _validate_input_path(raw: str) -> bool | str:
|
|
48
|
+
path = Path(raw)
|
|
49
|
+
if not path.exists():
|
|
50
|
+
return "Path must exist"
|
|
51
|
+
|
|
52
|
+
if path.is_dir():
|
|
53
|
+
if (path / "conversations.json").exists():
|
|
54
|
+
return True
|
|
55
|
+
return "Directory must contain conversations.json"
|
|
56
|
+
|
|
57
|
+
if path.suffix.lower() == ".json":
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
if path.suffix.lower() == ".zip":
|
|
61
|
+
return True if validate_zip(path) else "ZIP must contain conversations.json"
|
|
62
|
+
|
|
63
|
+
return "Input must be a .zip, a .json, or a directory containing conversations.json"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def run_interactive_config(initial_config: ConvovizConfig | None = None) -> ConvovizConfig:
|
|
67
|
+
"""Run interactive prompts to configure convoviz.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
initial_config: Optional starting configuration (uses defaults if None)
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Updated configuration based on user input
|
|
74
|
+
"""
|
|
75
|
+
config = initial_config or get_default_config()
|
|
76
|
+
|
|
77
|
+
# Set sensible defaults if not already set
|
|
78
|
+
if not config.input_path:
|
|
79
|
+
latest = find_latest_zip()
|
|
80
|
+
if latest:
|
|
81
|
+
config.input_path = latest
|
|
82
|
+
|
|
83
|
+
if not config.wordcloud.font_path:
|
|
84
|
+
config.wordcloud.font_path = default_font_path()
|
|
85
|
+
|
|
86
|
+
# Prompt for input path
|
|
87
|
+
input_default = str(config.input_path) if config.input_path else ""
|
|
88
|
+
input_result: str = _ask_or_cancel(
|
|
89
|
+
qst_path(
|
|
90
|
+
"Enter the path to the export ZIP, conversations JSON, or extracted directory:",
|
|
91
|
+
default=input_default,
|
|
92
|
+
validate=_validate_input_path,
|
|
93
|
+
style=CUSTOM_STYLE,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if input_result:
|
|
98
|
+
config.input_path = Path(input_result)
|
|
99
|
+
|
|
100
|
+
# Prompt for output folder
|
|
101
|
+
output_result: str = _ask_or_cancel(
|
|
102
|
+
qst_path(
|
|
103
|
+
"Enter the path to the output folder:",
|
|
104
|
+
default=str(config.output_folder),
|
|
105
|
+
style=CUSTOM_STYLE,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if output_result:
|
|
110
|
+
config.output_folder = Path(output_result)
|
|
111
|
+
|
|
112
|
+
# Prompt for author headers
|
|
113
|
+
headers = config.message.author_headers
|
|
114
|
+
for role in ["system", "user", "assistant", "tool"]:
|
|
115
|
+
current = getattr(headers, role)
|
|
116
|
+
result: str = _ask_or_cancel(
|
|
117
|
+
qst_text(
|
|
118
|
+
f"Enter the message header for '{role}':",
|
|
119
|
+
default=current,
|
|
120
|
+
validate=lambda t: validate_header(t)
|
|
121
|
+
or "Must be a valid markdown header (e.g., # Title)",
|
|
122
|
+
style=CUSTOM_STYLE,
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
if result:
|
|
126
|
+
setattr(headers, role, result)
|
|
127
|
+
|
|
128
|
+
# Prompt for LaTeX delimiters
|
|
129
|
+
latex_result = cast(
|
|
130
|
+
Literal["default", "dollars"],
|
|
131
|
+
_ask_or_cancel(
|
|
132
|
+
select(
|
|
133
|
+
"Select the LaTeX math delimiters:",
|
|
134
|
+
choices=["default", "dollars"],
|
|
135
|
+
default=config.conversation.markdown.latex_delimiters,
|
|
136
|
+
style=CUSTOM_STYLE,
|
|
137
|
+
)
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if latex_result:
|
|
142
|
+
config.conversation.markdown.latex_delimiters = latex_result
|
|
143
|
+
|
|
144
|
+
# Prompt for markdown flavor
|
|
145
|
+
flavor_result = cast(
|
|
146
|
+
Literal["obsidian", "standard"],
|
|
147
|
+
_ask_or_cancel(
|
|
148
|
+
select(
|
|
149
|
+
"Select the markdown flavor:",
|
|
150
|
+
choices=["obsidian", "standard"],
|
|
151
|
+
default=config.conversation.markdown.flavor,
|
|
152
|
+
style=CUSTOM_STYLE,
|
|
153
|
+
)
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if flavor_result:
|
|
158
|
+
config.conversation.markdown.flavor = flavor_result
|
|
159
|
+
|
|
160
|
+
# Prompt for YAML headers
|
|
161
|
+
yaml_config = config.conversation.yaml
|
|
162
|
+
yaml_choices = [
|
|
163
|
+
Choice(title=field, checked=getattr(yaml_config, field))
|
|
164
|
+
for field in [
|
|
165
|
+
"title",
|
|
166
|
+
"tags",
|
|
167
|
+
"chat_link",
|
|
168
|
+
"create_time",
|
|
169
|
+
"update_time",
|
|
170
|
+
"model",
|
|
171
|
+
"used_plugins",
|
|
172
|
+
"message_count",
|
|
173
|
+
"content_types",
|
|
174
|
+
"custom_instructions",
|
|
175
|
+
]
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
selected: list[str] = _ask_or_cancel(
|
|
179
|
+
checkbox(
|
|
180
|
+
"Select YAML metadata headers to include:",
|
|
181
|
+
choices=yaml_choices,
|
|
182
|
+
style=CUSTOM_STYLE,
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
selected_set = set(selected)
|
|
187
|
+
for field_name in [
|
|
188
|
+
"title",
|
|
189
|
+
"tags",
|
|
190
|
+
"chat_link",
|
|
191
|
+
"create_time",
|
|
192
|
+
"update_time",
|
|
193
|
+
"model",
|
|
194
|
+
"used_plugins",
|
|
195
|
+
"message_count",
|
|
196
|
+
"content_types",
|
|
197
|
+
"custom_instructions",
|
|
198
|
+
]:
|
|
199
|
+
setattr(yaml_config, field_name, field_name in selected_set)
|
|
200
|
+
|
|
201
|
+
# Prompt for font
|
|
202
|
+
available_fonts = font_names()
|
|
203
|
+
if available_fonts:
|
|
204
|
+
current_font = (
|
|
205
|
+
config.wordcloud.font_path.stem if config.wordcloud.font_path else available_fonts[0]
|
|
206
|
+
)
|
|
207
|
+
font_result: str = _ask_or_cancel(
|
|
208
|
+
select(
|
|
209
|
+
"Select the font for word clouds:",
|
|
210
|
+
choices=available_fonts,
|
|
211
|
+
default=current_font if current_font in available_fonts else available_fonts[0],
|
|
212
|
+
style=CUSTOM_STYLE,
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if font_result:
|
|
217
|
+
config.wordcloud.font_path = font_path(font_result)
|
|
218
|
+
|
|
219
|
+
# Prompt for colormap
|
|
220
|
+
available_colormaps = colormaps()
|
|
221
|
+
if available_colormaps:
|
|
222
|
+
colormap_result: str = _ask_or_cancel(
|
|
223
|
+
select(
|
|
224
|
+
"Select the color theme for word clouds:",
|
|
225
|
+
choices=available_colormaps,
|
|
226
|
+
default=config.wordcloud.colormap
|
|
227
|
+
if config.wordcloud.colormap in available_colormaps
|
|
228
|
+
else available_colormaps[0],
|
|
229
|
+
style=CUSTOM_STYLE,
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if colormap_result:
|
|
234
|
+
config.wordcloud.colormap = colormap_result
|
|
235
|
+
|
|
236
|
+
# Prompt for custom stopwords
|
|
237
|
+
stopwords_result: str = _ask_or_cancel(
|
|
238
|
+
qst_text(
|
|
239
|
+
"Enter custom stopwords (comma-separated):",
|
|
240
|
+
default=config.wordcloud.custom_stopwords,
|
|
241
|
+
style=CUSTOM_STYLE,
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
config.wordcloud.custom_stopwords = stopwords_result
|
|
246
|
+
|
|
247
|
+
return config
|