Studio: count fork clones per message and drop future streaks
- A pre-fork message can be pruned from its original thread while the clone stays in the fork. The suppression only checked that the source thread existed, so that message's tokens and activity vanished. Clones are now matched to the row they came from, and one fork per source is elected to stand in whenever the original is gone, whether the whole thread or just that message went. - Future-dated history was filtered out of the current streak but still padded the longest streak and could be reported as the last active day. It is dropped before any streak field is computed. - Cumulative mode labelled its tooltip "week of" while showing the running total. It now reports that week's tokens; the bar height still uses the running total. - The activity grid and weekday axis formatted dates with the browser language rather than the language chosen in Settings, and the memos could not react to a change. They follow the app locale now. The fork fixtures were also corrected: fork_chat_thread copies created_at verbatim, so a clone carries the original's timestamp, which the previous fixtures did not model.
This commit is contained in:
parent
f9970b8744
commit
485684db5f
4 changed files with 138 additions and 42 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue