diff --git a/studio/backend/storage/profile_stats_db.py b/studio/backend/storage/profile_stats_db.py index 8c1332c46e..795da78b7d 100644 --- a/studio/backend/storage/profile_stats_db.py +++ b/studio/backend/storage/profile_stats_db.py @@ -109,7 +109,12 @@ def _streaks(days: set[date], today: date) -> dict[str, Any]: The current streak survives a day that has not been used yet: a streak that ended yesterday is still "live" until today is over. + + Imported history or a skewed client clock can date rows in the future. + Those are dropped up front so they cannot pad the longest streak or be + reported as the last active day either. """ + days = {day for day in days if day <= today} if not days: return {"current": 0, "longest": 0, "lastActiveDay": None} @@ -122,9 +127,7 @@ def _streaks(days: set[date], today: date) -> dict[str, Any]: last = ordered[-1] current_streak = 0 - # A future-dated row (client clock ahead) is not a live streak: the last - # active day still has to be today or yesterday. - if timedelta(0) <= today - last <= timedelta(days = 1): + if today - last <= timedelta(days = 1): current_streak = 1 cursor = last while cursor - timedelta(days = 1) in days: @@ -186,14 +189,15 @@ class _MessageFold: bucket["threads"].add(thread_id) -def _orphan_fork_keepers(conn) -> set[str]: - """Forks whose source is gone and that should still count their copies. +def _fork_keepers(conn) -> set[str]: + """One fork per source, elected to stand in for originals that are gone. - ``forked_from_thread_id`` is not a foreign key, so deleting a source leaves - its forks holding the only copies of that ancestry. Enabling all of them - would multiply the usage by the number of siblings, so exactly one per dead - source is kept: the one carrying the most copied rows, which is the fork - that branched latest and therefore holds the longest ancestry. + A clone is normally ignored because the original is counted instead. When + the original is not there any more, whether its thread was deleted or just + that message was pruned, the clones become the only record. Letting every + sibling count them would multiply the usage, so exactly one fork per source + is allowed to: the one holding the most copied rows, which is the fork that + branched latest and therefore carries the longest ancestry. """ rows = conn.execute( """ @@ -202,7 +206,6 @@ def _orphan_fork_keepers(conn) -> set[str]: WHERE m.thread_id = t.id AND m.created_at < t.created_at) AS copied FROM chat_threads t WHERE t.forked_from_thread_id IS NOT NULL - AND NOT EXISTS (SELECT 1 FROM chat_threads s WHERE s.id = t.forked_from_thread_id) """ ).fetchall() @@ -215,21 +218,36 @@ def _orphan_fork_keepers(conn) -> set[str]: return {thread_id for _, thread_id in best.values()} +def _surviving_original_keys(conn) -> set[tuple[str, int, str]]: + """Identity of every message still living in a thread that has been forked. + + Clones get fresh ids, so there is nothing to join on. Within one thread the + timestamp and role are enough to recognise the row a clone was taken from. + """ + rows = conn.execute( + """ + SELECT m.thread_id, m.created_at, m.role + FROM chat_messages m + WHERE m.thread_id IN ( + SELECT DISTINCT forked_from_thread_id FROM chat_threads + WHERE forked_from_thread_id IS NOT NULL + ) + """ + ) + return {(row["thread_id"], _as_int(row["created_at"]), row["role"]) for row in rows} + + def _fold_messages(conn, zone) -> _MessageFold: fold = _MessageFold() - keepers = _orphan_fork_keepers(conn) + keepers = _fork_keepers(conn) + surviving = _surviving_original_keys(conn) rows = conn.execute( """ SELECT m.thread_id, m.role, m.metadata_json, m.attachments_json, m.created_at, t.title, t.model_id, t.model_type, t.pair_id, - t.created_at AS thread_created_at, t.forked_from_thread_id, - src.id AS fork_source_id + t.created_at AS thread_created_at, t.forked_from_thread_id FROM chat_messages m LEFT JOIN chat_threads t ON t.id = m.thread_id - -- forked_from_thread_id is not a foreign key, so it outlives the source. - -- Joining to the real row means the copies are only suppressed while - -- the originals are still there to be counted. - LEFT JOIN chat_threads src ON src.id = t.forked_from_thread_id ORDER BY m.thread_id, m.created_at """ ) @@ -274,16 +292,14 @@ def _fold_messages(conn, zone) -> _MessageFold: fold.threads.add(conversation_id) # Forking clones the whole ancestry into the new thread, keeping each - # copy's original timestamp. Counting those again would double every - # metric for the branched-from conversation, so skip anything older - # than the fork itself. Once the source is gone one fork is elected to - # keep its copies, since they are then the only record left. - if ( - row["forked_from_thread_id"] - and created_at < _as_int(row["thread_created_at"]) - and (row["fork_source_id"] or thread_id not in keepers) - ): - continue + # copy's original timestamp. Skip a clone while the row it was taken + # from is still there to be counted; once that original is gone, only + # the elected fork stands in for it, so nothing is lost or doubled. + source_id = row["forked_from_thread_id"] + if source_id and created_at < _as_int(row["thread_created_at"]): + original = (source_id, created_at, row["role"]) + if original in surviving or thread_id not in keepers: + continue fold.messages += 1 thread_messages += 1 diff --git a/studio/backend/tests/test_profile_stats.py b/studio/backend/tests/test_profile_stats.py index f5a00c3ec9..aee1578c90 100644 --- a/studio/backend/tests/test_profile_stats.py +++ b/studio/backend/tests/test_profile_stats.py @@ -23,6 +23,11 @@ def stats_db(tmp_path, monkeypatch): invalidate_profile_stats_cache() +# _seed_thread writes the assistant reply this far after the user turn, and +# fork_chat_thread copies created_at verbatim, so clones must reuse it. +REPLY_DELAY = timedelta(seconds = 10) + + def _ms(when: datetime) -> int: return int(when.timestamp() * 1000) @@ -55,7 +60,7 @@ def _seed_thread(conn, thread_id: str, model_id: str, turns: list[tuple[datetime "assistant", json.dumps([{"type": "text", "text": "hello"}]), json.dumps(metadata), - _ms(when + timedelta(seconds = 10)), + _ms(when + REPLY_DELAY), ), ) @@ -309,7 +314,7 @@ def test_forked_threads_do_not_double_count_copied_history(stats_db): conn.execute( "INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, " "created_at) VALUES ('fork-a0', 'fork', 'assistant', '[]', ?, ?)", - (json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2))), + (json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2) + REPLY_DELAY)), ) conn.commit() finally: @@ -418,7 +423,7 @@ def test_forks_count_as_chats_before_their_first_new_turn(stats_db): conn.execute( "INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, " "created_at) VALUES ('branch-a0', 'branch', 'assistant', '[]', ?, ?)", - (json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2))), + (json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2) + REPLY_DELAY)), ) conn.commit() finally: @@ -537,7 +542,7 @@ def test_deleting_the_source_thread_keeps_the_forks_copies(stats_db): conn.execute( "INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, " "created_at) VALUES ('kept-a0', 'kept', 'assistant', '[]', ?, ?)", - (json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2))), + (json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2) + REPLY_DELAY)), ) conn.commit() finally: @@ -577,7 +582,12 @@ def test_sibling_forks_do_not_multiply_a_deleted_source(stats_db): conn.execute( "INSERT INTO chat_messages (id, thread_id, role, content_json, " "metadata_json, created_at) VALUES (?, ?, 'assistant', '[]', ?, ?)", - (f"{fork_id}-a0", fork_id, json.dumps(_metadata(100, 50)), _ms(older)), + ( + f"{fork_id}-a0", + fork_id, + json.dumps(_metadata(100, 50)), + _ms(older + REPLY_DELAY), + ), ) conn.commit() finally: @@ -600,6 +610,67 @@ def test_sibling_forks_do_not_multiply_a_deleted_source(stats_db): assert stats["totals"]["messages"] == 1 +def test_deleting_an_original_message_keeps_the_forks_clone(stats_db): + """Pruning one pre-fork message leaves the clone as the only copy.""" + now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0) + older = now - timedelta(hours = 3) + conn = studio_db.get_connection() + try: + _seed_thread(conn, "orig", "m", [(older, _metadata(100, 50))]) + conn.execute( + "INSERT INTO chat_threads (id, title, model_type, model_id, created_at, " + "updated_at, forked_from_thread_id, forked_from_message_id) " + "VALUES ('branch', 'fork', 'base', 'm', ?, ?, 'orig', 'orig-a0')", + (_ms(now), _ms(now)), + ) + # The clone keeps the original's timestamp and role. + conn.execute( + "INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, " + "created_at) VALUES ('branch-a0', 'branch', 'assistant', '[]', ?, ?)", + (json.dumps(_metadata(100, 50)), _ms(older + timedelta(seconds = 10))), + ) + conn.commit() + finally: + conn.close() + + # While the original is there the clone is ignored. + assert compute_profile_stats(days = 7)["totals"]["totalTokens"] == 150 + + conn = studio_db.get_connection() + try: + conn.execute("DELETE FROM chat_messages WHERE id = 'orig-a0'") + conn.commit() + finally: + conn.close() + + invalidate_profile_stats_cache() + stats = compute_profile_stats(days = 7) + + # The thread survives but that message does not, so the clone stands in. + assert stats["totals"]["totalTokens"] == 150 + assert stats["totals"]["assistantMessages"] == 1 + + +def test_future_history_cannot_pad_the_longest_streak(stats_db): + """Only the current streak was guarded; longest and lastActiveDay were not.""" + base = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0) + conn = studio_db.get_connection() + try: + _seed_thread( + conn, + "skew", + "m", + [(base + timedelta(days = day), _metadata(10, 10)) for day in (3, 4, 5, 6)], + ) + conn.commit() + finally: + conn.close() + + streak = compute_profile_stats(days = 366)["streak"] + + assert streak == {"current": 0, "longest": 0, "lastActiveDay": None} + + def test_comparison_panes_count_as_one_chat(stats_db): """Compare mode stores a thread per pane; the sidebar shows one chat.""" now = datetime.now() diff --git a/studio/frontend/src/features/profile/components/stats/rhythm-card.tsx b/studio/frontend/src/features/profile/components/stats/rhythm-card.tsx index d5c7cf6a2d..99e0f04838 100644 --- a/studio/frontend/src/features/profile/components/stats/rhythm-card.tsx +++ b/studio/frontend/src/features/profile/components/stats/rhythm-card.tsx @@ -7,7 +7,7 @@ import { ChartTooltipContent, } from "@/components/ui/chart"; import type { ChartConfig } from "@/components/ui/chart"; -import { useT } from "@/i18n"; +import { useLocale, useT } from "@/i18n"; import { useMemo } from "react"; import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; import type { ProfileStats } from "../../api/profile-stats"; @@ -92,15 +92,18 @@ export function HourRhythmCard({ stats }: { stats: ProfileStats }) { /** Weekday distribution, Monday-first to match the activity grid columns. */ export function WeekdayRhythmCard({ stats }: { stats: ProfileStats }) { const t = useT(); + // The app language, so the axis matches the rest of the panel when the user + // has picked a language that differs from the browser's. + const locale = useLocale(); const names = useMemo(() => { - const formatter = new Intl.DateTimeFormat(navigator.language, { + const formatter = new Intl.DateTimeFormat(locale, { weekday: "short", }); // 2024-01-01 was a Monday, so this walks Mon..Sun in the user's locale. return Array.from({ length: 7 }, (_, index) => formatter.format(new Date(2024, 0, 1 + index)), ); - }, []); + }, [locale]); const data = useMemo( () => diff --git a/studio/frontend/src/features/profile/components/stats/token-activity-card.tsx b/studio/frontend/src/features/profile/components/stats/token-activity-card.tsx index 3a9565c471..4e6b1230bd 100644 --- a/studio/frontend/src/features/profile/components/stats/token-activity-card.tsx +++ b/studio/frontend/src/features/profile/components/stats/token-activity-card.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -import { useT } from "@/i18n"; +import { useLocale, useT } from "@/i18n"; import { cn } from "@/lib/utils"; import { useEffect, useMemo, useRef, useState } from "react"; import type { ProfileStatsDay } from "../../api/profile-stats"; @@ -214,10 +214,12 @@ function BarColumn({ const t = useT(); const summary = columnSummary(column); const height = barHeight(summary.value, peak); + // The bar is scaled by summary.value, which in cumulative mode is the + // running total. The tooltip says "week of", so it reports that week. const title = summary.firstDay ? t("settings.profile.stats.weekTooltip", { date: dateFormatter.format(parseDayKey(summary.firstDay)), - tokens: formatFullNumber(summary.value), + tokens: formatFullNumber(summary.tokens), }) : ""; @@ -246,9 +248,13 @@ export function TokenActivityCard({ daily }: { daily: ProfileStatsDay[] }) { () => buildColumns(daily, values, columns), [daily, values, columns], ); + // The app language, not the browser's: those differ whenever the user picks + // a language in Settings, and it has to be a dependency so switching while + // the panel is open rebuilds the formatters. + const locale = useLocale(); const monthLabels = useMemo( - () => buildMonthLabels(grid, navigator.language), - [grid], + () => buildMonthLabels(grid, locale), + [grid, locale], ); // Daily scales against the busiest day, the bar modes against the busiest // column, so a full-height bar always means the peak week. @@ -281,12 +287,12 @@ export function TokenActivityCard({ daily }: { daily: ProfileStatsDay[] }) { const dateFormatter = useMemo( () => - new Intl.DateTimeFormat(navigator.language, { + new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", year: "numeric", }), - [], + [locale], ); return (