feat: 完善公开任务预览和用户问答历史
This commit is contained in:
1 parent
e7c745a512
commit
0001f86826
5 files changed
+984
-109
No files matched your search
@@ -15,7 +15,9 @@ import { HashRouter, useLocation } from "react-router-dom";
|
|||||||
import LandingPage from "./pages/LandingPage";
|
import LandingPage from "./pages/LandingPage";
|
||||||
import LoginPage from "./pages/LoginPage";
|
import LoginPage from "./pages/LoginPage";
|
||||||
import PageRoutes from "./pages/PageRoutes";
|
import PageRoutes from "./pages/PageRoutes";
|
||||||
import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage";
|
import PublicScheduledTaskPage, {
|
||||||
|
PublicScheduledTaskFullscreenPage,
|
||||||
|
} from "./pages/PublicScheduledTaskPage";
|
||||||
import PublicSharePage from "./pages/PublicSharePage";
|
import PublicSharePage from "./pages/PublicSharePage";
|
||||||
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
|
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
|
||||||
import EmbedChatPage from "./pages/EmbedChatPage";
|
import EmbedChatPage from "./pages/EmbedChatPage";
|
||||||
@@ -78,6 +80,10 @@ export function App() {
|
|||||||
path="/public/scheduled-tasks/:taskId"
|
path="/public/scheduled-tasks/:taskId"
|
||||||
element={<PublicScheduledTaskPage />}
|
element={<PublicScheduledTaskPage />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/public/scheduled-tasks/:taskId/runs/:runId/fullscreen"
|
||||||
|
element={<PublicScheduledTaskFullscreenPage />}
|
||||||
|
/>
|
||||||
{/* Embedded workspace Q&A — no page/workspace sidebars; parent passes message/username/theme */}
|
{/* Embedded workspace Q&A — no page/workspace sidebars; parent passes message/username/theme */}
|
||||||
<Route
|
<Route
|
||||||
path="/embed/chats/:thread_id"
|
path="/embed/chats/:thread_id"
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { apiFetch } from "@/core/api";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
|
export interface UserQaItem {
|
||||||
|
run_id: string;
|
||||||
|
thread_id: string;
|
||||||
|
thread_title: string;
|
||||||
|
assistant_id: string;
|
||||||
|
status: string;
|
||||||
|
model_name: string;
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
total_tokens: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserQaPageResponse {
|
||||||
|
user_id: string;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
total: number;
|
||||||
|
items: UserQaItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parse<T>(res: Response, what: string): Promise<T> {
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||||
|
throw new Error(err.detail ?? `${what} (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMyQa(
|
||||||
|
params: { page?: number; pageSize?: number; q?: string } = {},
|
||||||
|
): Promise<UserQaPageResponse> {
|
||||||
|
const search = new URLSearchParams({
|
||||||
|
page: String(params.page ?? 1),
|
||||||
|
page_size: String(params.pageSize ?? 20),
|
||||||
|
});
|
||||||
|
const query = (params.q ?? "").trim();
|
||||||
|
if (query) search.set("q", query);
|
||||||
|
return parse(
|
||||||
|
await apiFetch(`${getBackendBaseURL()}/api/users/me/qa?${search}`),
|
||||||
|
"Failed to load my Q&A",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,16 +6,18 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Download,
|
Download,
|
||||||
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
List,
|
List,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Maximize2,
|
||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams, useSearchParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -127,6 +129,10 @@ async function fetchPublicTaskRuns(taskId: string): Promise<PublicRunsResponse>
|
|||||||
return fetchJson<PublicRunsResponse>(`${publicBaseUrl()}/${encodeURIComponent(taskId)}/runs?limit=200`);
|
return fetchJson<PublicRunsResponse>(`${publicBaseUrl()}/${encodeURIComponent(taskId)}/runs?limit=200`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchPublicRun(runId: string): Promise<PublicRun> {
|
||||||
|
return fetchJson<PublicRun>(`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchPublicRunFile(runId: string, index: number): Promise<PublicFileContent> {
|
async function fetchPublicRunFile(runId: string, index: number): Promise<PublicFileContent> {
|
||||||
return fetchJson<PublicFileContent>(
|
return fetchJson<PublicFileContent>(
|
||||||
`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/files/${index}`,
|
`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/files/${index}`,
|
||||||
@@ -667,12 +673,61 @@ type ContentTab = {
|
|||||||
fileName?: string;
|
fileName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PublicFullscreenPreview =
|
||||||
|
| { mode: "html"; title: string; html: string }
|
||||||
|
| { mode: "markdown"; title: string; content: string }
|
||||||
|
| { mode: "text"; title: string; content: string }
|
||||||
|
| { mode: "file"; title: string; file: PublicFileContent };
|
||||||
|
|
||||||
|
function PublicFullscreenPreviewContent({ preview }: { preview: PublicFullscreenPreview }) {
|
||||||
|
if (preview.mode === "html") {
|
||||||
|
return <HtmlPreviewFrame html={preview.html} className="h-full w-full border-0 bg-white" />;
|
||||||
|
}
|
||||||
|
if (preview.mode === "markdown") {
|
||||||
|
return (
|
||||||
|
<MarkdownContent
|
||||||
|
className="max-w-none"
|
||||||
|
content={preview.content || "无正文内容"}
|
||||||
|
isLoading={false}
|
||||||
|
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||||
|
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (preview.mode === "file") {
|
||||||
|
const content = preview.file.content || "无文件内容";
|
||||||
|
if (isMarkdownFile(preview.file)) {
|
||||||
|
return (
|
||||||
|
<MarkdownContent
|
||||||
|
className="max-w-none"
|
||||||
|
content={content}
|
||||||
|
isLoading={false}
|
||||||
|
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||||
|
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <pre className="whitespace-pre-wrap break-words text-sm">{content}</pre>;
|
||||||
|
}
|
||||||
|
return <pre className="whitespace-pre-wrap break-words text-sm">{preview.content || "无正文内容"}</pre>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicFullscreenHref(taskId: string, runId: string, tab: ContentTab): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("tab", tab.kind);
|
||||||
|
if (tab.kind === "file" && tab.fileIndex != null) {
|
||||||
|
params.set("file", String(tab.fileIndex));
|
||||||
|
}
|
||||||
|
return `#/public/scheduled-tasks/${encodeURIComponent(taskId)}/runs/${encodeURIComponent(runId)}/fullscreen?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Right-pane controller for a single run: builds the tab list (body + MDs),
|
* Right-pane controller for a single run: builds the tab list (body + MDs),
|
||||||
* fetches each tab's source text, and hosts the view/edit toggle.
|
* fetches each tab's source text, and hosts the view/edit toggle.
|
||||||
*/
|
*/
|
||||||
function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [fullscreenPreview, setFullscreenPreview] = useState<PublicFullscreenPreview | null>(null);
|
||||||
|
|
||||||
// HTML page run: render a sandboxed iframe preview instead of the MD tabs.
|
// HTML page run: render a sandboxed iframe preview instead of the MD tabs.
|
||||||
const isHtmlRun =
|
const isHtmlRun =
|
||||||
@@ -837,9 +892,67 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
|||||||
const fileIndex = activeTabMeta?.fileIndex;
|
const fileIndex = activeTabMeta?.fileIndex;
|
||||||
const fileIsLoading = isFileTab && fileIndex != null ? !!fileLoading[fileIndex] : false;
|
const fileIsLoading = isFileTab && fileIndex != null ? !!fileLoading[fileIndex] : false;
|
||||||
const showEmptyHint = !activeSource && !fileIsLoading;
|
const showEmptyHint = !activeSource && !fileIsLoading;
|
||||||
|
const fullscreenHref = activeTabMeta ? publicFullscreenHref(taskId, run.id, activeTabMeta) : "";
|
||||||
|
const fullscreenDisabled =
|
||||||
|
!activeTabMeta ||
|
||||||
|
(activeTabMeta.kind === "html" ? !htmlContent : activeTabMeta.kind === "file" ? fileIsLoading : false);
|
||||||
|
|
||||||
|
const handleOpenFullscreen = useCallback(() => {
|
||||||
|
if (!activeTabMeta) return;
|
||||||
|
if (activeTabMeta.kind === "html") {
|
||||||
|
if (!htmlContent) return;
|
||||||
|
setFullscreenPreview({
|
||||||
|
mode: "html",
|
||||||
|
title: run.result?.title || "HTML 预览",
|
||||||
|
html: htmlContent,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeTabMeta.kind === "body") {
|
||||||
|
setFullscreenPreview({
|
||||||
|
mode: run.result?.format === "markdown" || run.result?.format === "mixed" ? "markdown" : "text",
|
||||||
|
title: run.result?.title || "正文预览",
|
||||||
|
content: activeSource || "无正文内容",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sourceFile = markdownFiles.find((file) => file.index === activeTabMeta.fileIndex);
|
||||||
|
setFullscreenPreview({
|
||||||
|
mode: "file",
|
||||||
|
title: activeTabMeta.fileName || sourceFile?.name || "文件预览",
|
||||||
|
file: {
|
||||||
|
index: activeTabMeta.fileIndex ?? 0,
|
||||||
|
name: activeTabMeta.fileName || sourceFile?.name || "result.md",
|
||||||
|
mime_type: sourceFile?.mime_type ?? "text/markdown",
|
||||||
|
kind: sourceFile?.kind ?? "markdown",
|
||||||
|
size: sourceFile?.size ?? activeSource.length,
|
||||||
|
previewable: true,
|
||||||
|
content: activeSource,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [activeTabMeta, activeSource, htmlContent, markdownFiles, run.result?.format, run.result?.title]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Dialog open={fullscreenPreview !== null} onOpenChange={(open) => !open && setFullscreenPreview(null)}>
|
||||||
|
<DialogContent className="flex h-[96vh] w-[96vw] max-w-none flex-col gap-0 p-0 sm:max-w-none">
|
||||||
|
<DialogHeader className="shrink-0 border-b px-4 py-3 pr-12">
|
||||||
|
<DialogTitle className="truncate text-base">
|
||||||
|
{fullscreenPreview?.title ?? "预览"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{fullscreenPreview && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"min-h-0 flex-1",
|
||||||
|
fullscreenPreview.mode === "html" ? "" : "overflow-auto p-4",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PublicFullscreenPreviewContent preview={fullscreenPreview} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
<CardHeader className="flex flex-wrap items-start justify-between gap-2 space-y-0">
|
<CardHeader className="flex flex-wrap items-start justify-between gap-2 space-y-0">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -859,6 +972,30 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{!isEditing && activeTabMeta && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleOpenFullscreen}
|
||||||
|
disabled={fullscreenDisabled}
|
||||||
|
title="全屏查看"
|
||||||
|
>
|
||||||
|
<Maximize2 className="size-4" />
|
||||||
|
全屏
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => window.open(fullscreenHref, "_blank", "noopener,noreferrer")}
|
||||||
|
disabled={fullscreenDisabled}
|
||||||
|
title="新页面打开全屏预览"
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
新页面
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{isFileTab && fileIndex != null && (
|
{isFileTab && fileIndex != null && (
|
||||||
<a
|
<a
|
||||||
className="bg-background hover:bg-accent inline-flex h-9 items-center gap-1 rounded-md border px-3 text-xs"
|
className="bg-background hover:bg-accent inline-flex h-9 items-center gap-1 rounded-md border px-3 text-xs"
|
||||||
@@ -999,3 +1136,153 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PublicScheduledTaskFullscreenPage() {
|
||||||
|
const { taskId, runId } = useParams<{ taskId: string; runId: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const requestedTab = searchParams.get("tab");
|
||||||
|
const tabKind: ContentTab["kind"] =
|
||||||
|
requestedTab === "body" || requestedTab === "file" || requestedTab === "html"
|
||||||
|
? requestedTab
|
||||||
|
: "html";
|
||||||
|
const fileIndexValue = Number(searchParams.get("file"));
|
||||||
|
const fileIndexValid = Number.isInteger(fileIndexValue) && fileIndexValue >= 0;
|
||||||
|
|
||||||
|
const { data: run, isLoading, isError, error } = useQuery({
|
||||||
|
queryKey: ["public-scheduled-task", "run", runId],
|
||||||
|
queryFn: () => fetchPublicRun(runId!),
|
||||||
|
enabled: !!runId,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isHtmlRun =
|
||||||
|
run?.result?.format === "html" ||
|
||||||
|
(run?.result?.files ?? []).some(
|
||||||
|
(file) => file.kind === "html" || (file.name ?? "").toLowerCase().endsWith(".html"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: htmlData, isLoading: htmlLoading } = useQuery({
|
||||||
|
queryKey: ["public-scheduled-task", "html", runId],
|
||||||
|
queryFn: () => fetchPublicRunHtml(runId!),
|
||||||
|
enabled: !!runId && tabKind === "html" && isHtmlRun && !run?.result?.html,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: fileData,
|
||||||
|
isLoading: fileLoading,
|
||||||
|
isError: fileIsError,
|
||||||
|
error: fileError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["public-scheduled-task", "file", runId, fileIndexValue],
|
||||||
|
queryFn: () => fetchPublicRunFile(runId!, fileIndexValue),
|
||||||
|
enabled: !!runId && tabKind === "file" && fileIndexValid,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const htmlContent = run?.result?.html ?? htmlData?.html ?? null;
|
||||||
|
const backHref = taskId ? `#/public/scheduled-tasks/${encodeURIComponent(taskId)}` : "#/public/scheduled-tasks";
|
||||||
|
const pageTitle =
|
||||||
|
tabKind === "file"
|
||||||
|
? fileData?.name || "文件预览"
|
||||||
|
: tabKind === "body"
|
||||||
|
? run?.result?.title || "正文预览"
|
||||||
|
: htmlData?.title || run?.result?.title || "HTML 预览";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={150}>
|
||||||
|
<div className="bg-background flex h-screen min-h-0 flex-col">
|
||||||
|
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
|
||||||
|
<Button asChild variant="ghost" size="sm" className="h-8 px-2 text-xs">
|
||||||
|
<a href={backHref}>
|
||||||
|
<ChevronLeft className="size-3" />
|
||||||
|
返回列表
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<div className="min-w-0 flex-1 truncate text-sm font-medium" title={pageTitle}>
|
||||||
|
{pageTitle}
|
||||||
|
</div>
|
||||||
|
{tabKind === "file" && runId && fileIndexValid && (
|
||||||
|
<Button asChild variant="outline" size="sm" className="h-8 px-2 text-xs">
|
||||||
|
<a
|
||||||
|
href={publicRunFileDownloadUrl(runId, fileIndexValue)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Download className="size-3" />
|
||||||
|
下载
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
{!runId || !taskId ? (
|
||||||
|
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
|
||||||
|
缺少任务或执行记录 ID。
|
||||||
|
</div>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
正在加载预览...
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="text-destructive flex h-full items-center justify-center text-sm">
|
||||||
|
加载失败:{error instanceof Error ? error.message : "未知错误"}
|
||||||
|
</div>
|
||||||
|
) : tabKind === "html" ? (
|
||||||
|
htmlContent ? (
|
||||||
|
<HtmlPreviewFrame html={htmlContent} className="h-full w-full border-0 bg-white" />
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
|
||||||
|
{htmlLoading && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
HTML 页面尚未生成或正在生成中。
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : tabKind === "file" ? (
|
||||||
|
fileLoading ? (
|
||||||
|
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
正在加载文件...
|
||||||
|
</div>
|
||||||
|
) : !fileIndexValid || fileIsError || !fileData ? (
|
||||||
|
<div className="text-destructive flex h-full items-center justify-center text-sm">
|
||||||
|
文件加载失败:{fileError instanceof Error ? fileError.message : "文件不存在或不可预览"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="mx-auto max-w-5xl p-6">
|
||||||
|
<PublicFullscreenPreviewContent
|
||||||
|
preview={{
|
||||||
|
mode: "file",
|
||||||
|
title: fileData.name,
|
||||||
|
file: fileData,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="mx-auto max-w-5xl p-6">
|
||||||
|
<PublicFullscreenPreviewContent
|
||||||
|
preview={{
|
||||||
|
mode:
|
||||||
|
run?.result?.format === "markdown" || run?.result?.format === "mixed"
|
||||||
|
? "markdown"
|
||||||
|
: "text",
|
||||||
|
title: run?.result?.title || "正文预览",
|
||||||
|
content: run?.result?.content || "无正文内容",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
from time import perf_counter
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
@@ -468,6 +470,84 @@ _LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS = _env_float(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _leaderboard_scheduler_sql_for_log(stmt) -> str:
|
||||||
|
try:
|
||||||
|
return str(stmt.compile(compile_kwargs={"literal_binds": True}))
|
||||||
|
except Exception:
|
||||||
|
return str(stmt)
|
||||||
|
|
||||||
|
|
||||||
|
async def _execute_leaderboard_scheduler_query(session, step: str, stmt, **context):
|
||||||
|
sql = _leaderboard_scheduler_sql_for_log(stmt)
|
||||||
|
logger.info(
|
||||||
|
"Leaderboard snapshot DB query start: step=%s stat_date=%s job=%s settings_hash=%s started_at=%s sql=%s",
|
||||||
|
step,
|
||||||
|
context.get("stat_date") or context.get("stat_dates") or "",
|
||||||
|
context.get("job_key") or "",
|
||||||
|
context.get("settings_hash") or "",
|
||||||
|
datetime.now().isoformat(),
|
||||||
|
sql,
|
||||||
|
)
|
||||||
|
start = perf_counter()
|
||||||
|
try:
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Leaderboard snapshot DB query failed: step=%s stat_date=%s job=%s settings_hash=%s elapsed_ms=%.2f",
|
||||||
|
step,
|
||||||
|
context.get("stat_date") or context.get("stat_dates") or "",
|
||||||
|
context.get("job_key") or "",
|
||||||
|
context.get("settings_hash") or "",
|
||||||
|
(perf_counter() - start) * 1000,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
logger.info(
|
||||||
|
"Leaderboard snapshot DB query done: step=%s stat_date=%s job=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
|
||||||
|
step,
|
||||||
|
context.get("stat_date") or context.get("stat_dates") or "",
|
||||||
|
context.get("job_key") or "",
|
||||||
|
context.get("settings_hash") or "",
|
||||||
|
datetime.now().isoformat(),
|
||||||
|
(perf_counter() - start) * 1000,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _time_leaderboard_scheduler_db_operation(step: str, operation, *, sql: str = "", **context):
|
||||||
|
logger.info(
|
||||||
|
"Leaderboard snapshot DB query start: step=%s stat_date=%s job=%s settings_hash=%s started_at=%s sql=%s",
|
||||||
|
step,
|
||||||
|
context.get("stat_date") or context.get("stat_dates") or "",
|
||||||
|
context.get("job_key") or "",
|
||||||
|
context.get("settings_hash") or "",
|
||||||
|
datetime.now().isoformat(),
|
||||||
|
sql,
|
||||||
|
)
|
||||||
|
start = perf_counter()
|
||||||
|
try:
|
||||||
|
result = await operation()
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Leaderboard snapshot DB query failed: step=%s stat_date=%s job=%s settings_hash=%s elapsed_ms=%.2f",
|
||||||
|
step,
|
||||||
|
context.get("stat_date") or context.get("stat_dates") or "",
|
||||||
|
context.get("job_key") or "",
|
||||||
|
context.get("settings_hash") or "",
|
||||||
|
(perf_counter() - start) * 1000,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
logger.info(
|
||||||
|
"Leaderboard snapshot DB query done: step=%s stat_date=%s job=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
|
||||||
|
step,
|
||||||
|
context.get("stat_date") or context.get("stat_dates") or "",
|
||||||
|
context.get("job_key") or "",
|
||||||
|
context.get("settings_hash") or "",
|
||||||
|
datetime.now().isoformat(),
|
||||||
|
(perf_counter() - start) * 1000,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _recent_leaderboard_stat_dates(days: int) -> list[str]:
|
def _recent_leaderboard_stat_dates(days: int) -> list[str]:
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
@@ -493,21 +573,27 @@ async def _leaderboard_snapshot_due_dates(
|
|||||||
|
|
||||||
cutoff = datetime.now(UTC) - timedelta(seconds=running_ttl_seconds)
|
cutoff = datetime.now(UTC) - timedelta(seconds=running_ttl_seconds)
|
||||||
async with session_factory() as session:
|
async with session_factory() as session:
|
||||||
rows = (
|
stmt = (
|
||||||
await session.execute(
|
select(AdminLeaderboardDailyStatRow.stat_date)
|
||||||
select(AdminLeaderboardDailyStatRow.stat_date)
|
.where(
|
||||||
.where(
|
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
|
||||||
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
|
or_(
|
||||||
or_(
|
AdminLeaderboardDailyStatRow.status.in_(["queued", "error"]),
|
||||||
AdminLeaderboardDailyStatRow.status.in_(["queued", "error"]),
|
and_(
|
||||||
and_(
|
AdminLeaderboardDailyStatRow.status == "running",
|
||||||
AdminLeaderboardDailyStatRow.status == "running",
|
AdminLeaderboardDailyStatRow.updated_at < cutoff,
|
||||||
AdminLeaderboardDailyStatRow.updated_at < cutoff,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
.order_by(AdminLeaderboardDailyStatRow.stat_date.desc())
|
)
|
||||||
.limit(limit)
|
.order_by(AdminLeaderboardDailyStatRow.stat_date.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
await _execute_leaderboard_scheduler_query(
|
||||||
|
session,
|
||||||
|
"scheduler.due_dates",
|
||||||
|
stmt,
|
||||||
|
settings_hash=settings_hash,
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
return [str(row[0]) for row in rows]
|
return [str(row[0]) for row in rows]
|
||||||
@@ -527,9 +613,15 @@ async def _claim_leaderboard_snapshot(
|
|||||||
from deerflow.persistence.types import BEIJING_TZ
|
from deerflow.persistence.types import BEIJING_TZ
|
||||||
|
|
||||||
async with session_factory() as session:
|
async with session_factory() as session:
|
||||||
row = await session.get(
|
row = await _time_leaderboard_scheduler_db_operation(
|
||||||
AdminLeaderboardDailyStatRow,
|
"scheduler.claim.get",
|
||||||
{"stat_date": stat_date, "settings_hash": settings_hash},
|
lambda: session.get(
|
||||||
|
AdminLeaderboardDailyStatRow,
|
||||||
|
{"stat_date": stat_date, "settings_hash": settings_hash},
|
||||||
|
),
|
||||||
|
sql="SELECT admin_leaderboard_daily_stats by primary key",
|
||||||
|
stat_date=stat_date,
|
||||||
|
settings_hash=settings_hash,
|
||||||
)
|
)
|
||||||
if row is None or _snapshot_is_fresh(row, stat_date):
|
if row is None or _snapshot_is_fresh(row, stat_date):
|
||||||
return False
|
return False
|
||||||
@@ -545,7 +637,13 @@ async def _claim_leaderboard_snapshot(
|
|||||||
row.message = "排行榜后台统计任务已认领。"
|
row.message = "排行榜后台统计任务已认领。"
|
||||||
row.error = None
|
row.error = None
|
||||||
row.updated_at = datetime.now(UTC)
|
row.updated_at = datetime.now(UTC)
|
||||||
await session.commit()
|
await _time_leaderboard_scheduler_db_operation(
|
||||||
|
"scheduler.claim.commit",
|
||||||
|
lambda: session.commit(),
|
||||||
|
sql="COMMIT",
|
||||||
|
stat_date=stat_date,
|
||||||
|
settings_hash=settings_hash,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -1152,10 +1250,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
# Admin LLM metrics audit + export
|
# Admin LLM metrics audit + export
|
||||||
app.include_router(llm_metrics.router)
|
app.include_router(llm_metrics.router)
|
||||||
|
|
||||||
# Admin window into other users' threads / messages / memory
|
# Admin window into other users' threads / messages / memory
|
||||||
app.include_router(admin_users.router)
|
app.include_router(admin_users.router)
|
||||||
|
app.include_router(admin_users.self_router)
|
||||||
# Admin tool / skill call metrics audit + export
|
|
||||||
|
# Admin tool / skill call metrics audit + export
|
||||||
app.include_router(tool_metrics.router)
|
app.include_router(tool_metrics.router)
|
||||||
|
|
||||||
# Channels API is mounted at /api/channels
|
# Channels API is mounted at /api/channels
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
from contextvars import ContextVar
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
|
from time import perf_counter
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from datetime import UTC, datetime, timedelta, time as datetime_time
|
from datetime import UTC, datetime, timedelta, time as datetime_time
|
||||||
|
|
||||||
@@ -56,6 +59,7 @@ from deerflow.persistence.user.model import UserRow
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/admin/users", tags=["admin-users"])
|
router = APIRouter(prefix="/api/admin/users", tags=["admin-users"])
|
||||||
|
self_router = APIRouter(prefix="/api/users/me", tags=["user-qa"])
|
||||||
|
|
||||||
_LEADERBOARD_MAX_CONCURRENT = max(
|
_LEADERBOARD_MAX_CONCURRENT = max(
|
||||||
1,
|
1,
|
||||||
@@ -99,6 +103,119 @@ _LEADERBOARD_QUEUE_ON_REQUEST_ENABLED = os.getenv(
|
|||||||
"off",
|
"off",
|
||||||
}
|
}
|
||||||
_leaderboard_semaphore = asyncio.Semaphore(_LEADERBOARD_MAX_CONCURRENT)
|
_leaderboard_semaphore = asyncio.Semaphore(_LEADERBOARD_MAX_CONCURRENT)
|
||||||
|
_LEADERBOARD_TRACE_CONTEXT: ContextVar[dict[str, object] | None] = ContextVar(
|
||||||
|
"leaderboard_trace_context",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _leaderboard_log_context(**context: object) -> dict[str, object] | None:
|
||||||
|
current = _LEADERBOARD_TRACE_CONTEXT.get()
|
||||||
|
merged: dict[str, object] = dict(current or {})
|
||||||
|
for key, value in context.items():
|
||||||
|
if value is None or value == "":
|
||||||
|
continue
|
||||||
|
merged[key] = value
|
||||||
|
return merged or None
|
||||||
|
|
||||||
|
|
||||||
|
def _leaderboard_sql_for_log(stmt) -> str:
|
||||||
|
try:
|
||||||
|
return str(stmt.compile(compile_kwargs={"literal_binds": True}))
|
||||||
|
except Exception:
|
||||||
|
return str(stmt)
|
||||||
|
|
||||||
|
|
||||||
|
async def _execute_leaderboard_query(session, step: str, stmt, **context: object):
|
||||||
|
log_context = _leaderboard_log_context(**context)
|
||||||
|
if not log_context:
|
||||||
|
return await session.execute(stmt)
|
||||||
|
|
||||||
|
started_at = datetime.now(BEIJING_TZ).isoformat()
|
||||||
|
sql = _leaderboard_sql_for_log(stmt)
|
||||||
|
logger.info(
|
||||||
|
"Leaderboard snapshot DB query start: step=%s stat_date=%s job=%s settings_hash=%s started_at=%s sql=%s",
|
||||||
|
step,
|
||||||
|
log_context.get("stat_date") or log_context.get("stat_dates") or "",
|
||||||
|
log_context.get("job_key") or "",
|
||||||
|
log_context.get("settings_hash") or "",
|
||||||
|
started_at,
|
||||||
|
sql,
|
||||||
|
)
|
||||||
|
start = perf_counter()
|
||||||
|
try:
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
except Exception:
|
||||||
|
elapsed_ms = (perf_counter() - start) * 1000
|
||||||
|
logger.exception(
|
||||||
|
"Leaderboard snapshot DB query failed: step=%s stat_date=%s job=%s settings_hash=%s elapsed_ms=%.2f",
|
||||||
|
step,
|
||||||
|
log_context.get("stat_date") or log_context.get("stat_dates") or "",
|
||||||
|
log_context.get("job_key") or "",
|
||||||
|
log_context.get("settings_hash") or "",
|
||||||
|
elapsed_ms,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
elapsed_ms = (perf_counter() - start) * 1000
|
||||||
|
logger.info(
|
||||||
|
"Leaderboard snapshot DB query done: step=%s stat_date=%s job=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
|
||||||
|
step,
|
||||||
|
log_context.get("stat_date") or log_context.get("stat_dates") or "",
|
||||||
|
log_context.get("job_key") or "",
|
||||||
|
log_context.get("settings_hash") or "",
|
||||||
|
datetime.now(BEIJING_TZ).isoformat(),
|
||||||
|
elapsed_ms,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _time_leaderboard_db_operation(
|
||||||
|
step: str,
|
||||||
|
operation,
|
||||||
|
*,
|
||||||
|
sql: str = "",
|
||||||
|
**context: object,
|
||||||
|
):
|
||||||
|
log_context = _leaderboard_log_context(**context)
|
||||||
|
if not log_context:
|
||||||
|
return await operation()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Leaderboard snapshot DB query start: step=%s stat_date=%s job=%s settings_hash=%s started_at=%s sql=%s",
|
||||||
|
step,
|
||||||
|
log_context.get("stat_date") or log_context.get("stat_dates") or "",
|
||||||
|
log_context.get("job_key") or "",
|
||||||
|
log_context.get("settings_hash") or "",
|
||||||
|
datetime.now(BEIJING_TZ).isoformat(),
|
||||||
|
sql,
|
||||||
|
)
|
||||||
|
start = perf_counter()
|
||||||
|
try:
|
||||||
|
result = await operation()
|
||||||
|
except Exception:
|
||||||
|
elapsed_ms = (perf_counter() - start) * 1000
|
||||||
|
logger.exception(
|
||||||
|
"Leaderboard snapshot DB query failed: step=%s stat_date=%s job=%s settings_hash=%s elapsed_ms=%.2f",
|
||||||
|
step,
|
||||||
|
log_context.get("stat_date") or log_context.get("stat_dates") or "",
|
||||||
|
log_context.get("job_key") or "",
|
||||||
|
log_context.get("settings_hash") or "",
|
||||||
|
elapsed_ms,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
elapsed_ms = (perf_counter() - start) * 1000
|
||||||
|
logger.info(
|
||||||
|
"Leaderboard snapshot DB query done: step=%s stat_date=%s job=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
|
||||||
|
step,
|
||||||
|
log_context.get("stat_date") or log_context.get("stat_dates") or "",
|
||||||
|
log_context.get("job_key") or "",
|
||||||
|
log_context.get("settings_hash") or "",
|
||||||
|
datetime.now(BEIJING_TZ).isoformat(),
|
||||||
|
elapsed_ms,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def _require_admin(request: Request) -> None:
|
async def _require_admin(request: Request) -> None:
|
||||||
@@ -285,6 +402,13 @@ def _parse_datetime(value: str | None, fallback: datetime) -> datetime:
|
|||||||
raise HTTPException(status_code=400, detail=f"Invalid datetime: {value}") from exc
|
raise HTTPException(status_code=400, detail=f"Invalid datetime: {value}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_datetime(value: str | None) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
parsed = _parse_datetime(value, _now())
|
||||||
|
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=BEIJING_TZ)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_range(days: int, since: str | None, until: str | None) -> tuple[datetime, datetime, int]:
|
def _resolve_range(days: int, since: str | None, until: str | None) -> tuple[datetime, datetime, int]:
|
||||||
end = _parse_datetime(until, _now())
|
end = _parse_datetime(until, _now())
|
||||||
start = _parse_datetime(since, end - timedelta(days=max(1, min(days, 365))))
|
start = _parse_datetime(since, end - timedelta(days=max(1, min(days, 365))))
|
||||||
@@ -325,7 +449,7 @@ def _bounds_for_stat_date(stat_date: str) -> tuple[datetime, datetime]:
|
|||||||
|
|
||||||
async def _try_set_session_value(session, sql: str) -> None:
|
async def _try_set_session_value(session, sql: str) -> None:
|
||||||
try:
|
try:
|
||||||
await session.execute(text(sql))
|
await _execute_leaderboard_query(session, "session_setting", text(sql))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to apply analytics session setting: %s", sql, exc_info=True)
|
logger.debug("Failed to apply analytics session setting: %s", sql, exc_info=True)
|
||||||
try:
|
try:
|
||||||
@@ -665,7 +789,7 @@ async def _query_user_leaderboard(
|
|||||||
desc(func.coalesce(run_agg.c.run_count, 0) + func.coalesce(tool_agg.c.tool_call_count, 0)),
|
desc(func.coalesce(run_agg.c.run_count, 0) + func.coalesce(tool_agg.c.tool_call_count, 0)),
|
||||||
desc(func.coalesce(run_agg.c.total_tokens, 0)),
|
desc(func.coalesce(run_agg.c.total_tokens, 0)),
|
||||||
)
|
)
|
||||||
rows = (await session.execute(stmt)).all()
|
rows = (await _execute_leaderboard_query(session, "users", stmt)).all()
|
||||||
return [
|
return [
|
||||||
AdminLeaderboardUser(
|
AdminLeaderboardUser(
|
||||||
user_id=r[0],
|
user_id=r[0],
|
||||||
@@ -725,7 +849,7 @@ async def _query_call_rankings(
|
|||||||
include_admins=include_admins,
|
include_admins=include_admins,
|
||||||
include_scheduled=include_scheduled,
|
include_scheduled=include_scheduled,
|
||||||
)
|
)
|
||||||
rows = (await session.execute(stmt)).all()
|
rows = (await _execute_leaderboard_query(session, f"{field}_rankings", stmt)).all()
|
||||||
return [
|
return [
|
||||||
AdminLeaderboardSkill(
|
AdminLeaderboardSkill(
|
||||||
name=r[0] or "",
|
name=r[0] or "",
|
||||||
@@ -791,7 +915,7 @@ async def _query_run_dimensions(
|
|||||||
include_failed=include_failed,
|
include_failed=include_failed,
|
||||||
include_scheduled=include_scheduled,
|
include_scheduled=include_scheduled,
|
||||||
)
|
)
|
||||||
rows = (await session.execute(stmt)).all()
|
rows = (await _execute_leaderboard_query(session, f"{field}_dimensions", stmt)).all()
|
||||||
return [
|
return [
|
||||||
AdminLeaderboardDimension(
|
AdminLeaderboardDimension(
|
||||||
name=r[0] or "",
|
name=r[0] or "",
|
||||||
@@ -848,7 +972,7 @@ async def _query_model_rankings(
|
|||||||
stmt = stmt.where(LlmCallMetricRow.status != "error")
|
stmt = stmt.where(LlmCallMetricRow.status != "error")
|
||||||
if not include_scheduled:
|
if not include_scheduled:
|
||||||
stmt = stmt.where(or_(LlmCallMetricRow.run_id.is_(None), LlmCallMetricRow.run_id.not_in(_scheduled_run_ids_stmt())))
|
stmt = stmt.where(or_(LlmCallMetricRow.run_id.is_(None), LlmCallMetricRow.run_id.not_in(_scheduled_run_ids_stmt())))
|
||||||
rows = (await session.execute(stmt)).all()
|
rows = (await _execute_leaderboard_query(session, "model_rankings", stmt)).all()
|
||||||
return [
|
return [
|
||||||
AdminLeaderboardDimension(
|
AdminLeaderboardDimension(
|
||||||
name=r[0] or "",
|
name=r[0] or "",
|
||||||
@@ -895,7 +1019,7 @@ async def _query_recent_questions(
|
|||||||
include_failed=include_failed,
|
include_failed=include_failed,
|
||||||
include_scheduled=include_scheduled,
|
include_scheduled=include_scheduled,
|
||||||
)
|
)
|
||||||
rows = (await session.execute(stmt)).all()
|
rows = (await _execute_leaderboard_query(session, "recent_questions", stmt)).all()
|
||||||
questions: list[AdminLeaderboardQuestion] = []
|
questions: list[AdminLeaderboardQuestion] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
text = _clean_question_text(r[0]) if cleanup_system_questions else str(r[0] or "").strip()
|
text = _clean_question_text(r[0]) if cleanup_system_questions else str(r[0] or "").strip()
|
||||||
@@ -947,7 +1071,7 @@ async def _query_frequent_questions(
|
|||||||
include_failed=include_failed,
|
include_failed=include_failed,
|
||||||
include_scheduled=include_scheduled,
|
include_scheduled=include_scheduled,
|
||||||
)
|
)
|
||||||
rows = (await session.execute(stmt)).all()
|
rows = (await _execute_leaderboard_query(session, "frequent_questions", stmt)).all()
|
||||||
groups: list[dict] = []
|
groups: list[dict] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
text = _clean_question_text(r[0]) if cleanup_system_questions else str(r[0] or "").strip()
|
text = _clean_question_text(r[0]) if cleanup_system_questions else str(r[0] or "").strip()
|
||||||
@@ -1022,7 +1146,7 @@ async def _query_trends(
|
|||||||
include_failed=include_failed,
|
include_failed=include_failed,
|
||||||
include_scheduled=include_scheduled,
|
include_scheduled=include_scheduled,
|
||||||
)
|
)
|
||||||
rows = (await session.execute(stmt)).all()
|
rows = (await _execute_leaderboard_query(session, "trends", stmt)).all()
|
||||||
return [
|
return [
|
||||||
AdminLeaderboardTrendPoint(
|
AdminLeaderboardTrendPoint(
|
||||||
date=str(r[0]),
|
date=str(r[0]),
|
||||||
@@ -1071,8 +1195,8 @@ async def _query_overview(
|
|||||||
|
|
||||||
total_users_stmt = select(func.count(UserRow.id))
|
total_users_stmt = select(func.count(UserRow.id))
|
||||||
total_users_stmt = _apply_user_scope(total_users_stmt, UserRow.id, exclude_user_ids=exclude_user_ids, include_admins=include_admins)
|
total_users_stmt = _apply_user_scope(total_users_stmt, UserRow.id, exclude_user_ids=exclude_user_ids, include_admins=include_admins)
|
||||||
total_users = 1 if user_id else (await session.execute(total_users_stmt)).scalar_one()
|
total_users = 1 if user_id else (await _execute_leaderboard_query(session, "overview.total_users", total_users_stmt)).scalar_one()
|
||||||
run_row = (await session.execute(
|
run_summary_stmt = (
|
||||||
select(
|
select(
|
||||||
func.count(RunRow.run_id),
|
func.count(RunRow.run_id),
|
||||||
_question_count_expr(),
|
_question_count_expr(),
|
||||||
@@ -1080,16 +1204,19 @@ async def _query_overview(
|
|||||||
func.count(func.distinct(RunRow.user_id)),
|
func.count(func.distinct(RunRow.user_id)),
|
||||||
func.sum(case((RunRow.status == "error", 1), else_=0)),
|
func.sum(case((RunRow.status == "error", 1), else_=0)),
|
||||||
).where(*run_where)
|
).where(*run_where)
|
||||||
)).one()
|
)
|
||||||
thread_count = (await session.execute(select(func.count(ThreadMetaRow.thread_id)).where(*thread_where))).scalar_one()
|
run_row = (await _execute_leaderboard_query(session, "overview.run_summary", run_summary_stmt)).one()
|
||||||
tool_row = (await session.execute(
|
thread_count_stmt = select(func.count(ThreadMetaRow.thread_id)).where(*thread_where)
|
||||||
|
thread_count = (await _execute_leaderboard_query(session, "overview.thread_count", thread_count_stmt)).scalar_one()
|
||||||
|
tool_summary_stmt = (
|
||||||
select(
|
select(
|
||||||
func.count(ToolCallMetricRow.id),
|
func.count(ToolCallMetricRow.id),
|
||||||
func.sum(case((func.nullif(ToolCallMetricRow.skill_name, "").is_not(None), 1), else_=0)),
|
func.sum(case((func.nullif(ToolCallMetricRow.skill_name, "").is_not(None), 1), else_=0)),
|
||||||
)
|
)
|
||||||
.outerjoin(RunRow, RunRow.run_id == ToolCallMetricRow.run_id)
|
.outerjoin(RunRow, RunRow.run_id == ToolCallMetricRow.run_id)
|
||||||
.where(*tool_where)
|
.where(*tool_where)
|
||||||
)).one()
|
)
|
||||||
|
tool_row = (await _execute_leaderboard_query(session, "overview.tool_summary", tool_summary_stmt)).one()
|
||||||
|
|
||||||
return AdminLeaderboardOverview(
|
return AdminLeaderboardOverview(
|
||||||
total_users=int(total_users or 0),
|
total_users=int(total_users or 0),
|
||||||
@@ -1579,13 +1706,12 @@ async def _load_daily_snapshot_rows(
|
|||||||
if not stat_dates:
|
if not stat_dates:
|
||||||
return {}
|
return {}
|
||||||
async with session_factory() as session:
|
async with session_factory() as session:
|
||||||
|
stmt = select(AdminLeaderboardDailyStatRow).where(
|
||||||
|
AdminLeaderboardDailyStatRow.stat_date.in_(stat_dates),
|
||||||
|
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
|
||||||
|
)
|
||||||
rows = (
|
rows = (
|
||||||
await session.execute(
|
await _execute_leaderboard_query(session, "snapshot_rows.load", stmt)
|
||||||
select(AdminLeaderboardDailyStatRow).where(
|
|
||||||
AdminLeaderboardDailyStatRow.stat_date.in_(stat_dates),
|
|
||||||
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
return {row.stat_date: row for row in rows}
|
return {row.stat_date: row for row in rows}
|
||||||
|
|
||||||
@@ -1603,9 +1729,15 @@ async def _store_daily_snapshot(
|
|||||||
) -> None:
|
) -> None:
|
||||||
async with session_factory() as session:
|
async with session_factory() as session:
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
row = await session.get(
|
row = await _time_leaderboard_db_operation(
|
||||||
AdminLeaderboardDailyStatRow,
|
"snapshot.store.get",
|
||||||
{"stat_date": stat_date, "settings_hash": settings_hash},
|
lambda: session.get(
|
||||||
|
AdminLeaderboardDailyStatRow,
|
||||||
|
{"stat_date": stat_date, "settings_hash": settings_hash},
|
||||||
|
),
|
||||||
|
sql="SELECT admin_leaderboard_daily_stats by primary key",
|
||||||
|
stat_date=stat_date,
|
||||||
|
settings_hash=settings_hash,
|
||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
row = AdminLeaderboardDailyStatRow(
|
row = AdminLeaderboardDailyStatRow(
|
||||||
@@ -1630,7 +1762,13 @@ async def _store_daily_snapshot(
|
|||||||
if generated_at is not None:
|
if generated_at is not None:
|
||||||
row.generated_at = generated_at
|
row.generated_at = generated_at
|
||||||
row.updated_at = now
|
row.updated_at = now
|
||||||
await session.commit()
|
await _time_leaderboard_db_operation(
|
||||||
|
"snapshot.store.commit",
|
||||||
|
lambda: session.commit(),
|
||||||
|
sql="COMMIT",
|
||||||
|
stat_date=stat_date,
|
||||||
|
settings_hash=settings_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _queue_daily_leaderboard_snapshots(
|
async def _queue_daily_leaderboard_snapshots(
|
||||||
@@ -1646,12 +1784,17 @@ async def _queue_daily_leaderboard_snapshots(
|
|||||||
return queued
|
return queued
|
||||||
async with session_factory() as session:
|
async with session_factory() as session:
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
|
stmt = select(AdminLeaderboardDailyStatRow).where(
|
||||||
|
AdminLeaderboardDailyStatRow.stat_date.in_(stat_dates),
|
||||||
|
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
|
||||||
|
)
|
||||||
rows = (
|
rows = (
|
||||||
await session.execute(
|
await _execute_leaderboard_query(
|
||||||
select(AdminLeaderboardDailyStatRow).where(
|
session,
|
||||||
AdminLeaderboardDailyStatRow.stat_date.in_(stat_dates),
|
"snapshot_queue.load_existing",
|
||||||
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
|
stmt,
|
||||||
)
|
stat_dates=stat_dates,
|
||||||
|
settings_hash=settings_hash,
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
rows_by_date = {row.stat_date: row for row in rows}
|
rows_by_date = {row.stat_date: row for row in rows}
|
||||||
@@ -1680,7 +1823,13 @@ async def _queue_daily_leaderboard_snapshots(
|
|||||||
row.updated_at = now
|
row.updated_at = now
|
||||||
queued.append(stat_date)
|
queued.append(stat_date)
|
||||||
if queued:
|
if queued:
|
||||||
await session.commit()
|
await _time_leaderboard_db_operation(
|
||||||
|
"snapshot_queue.commit",
|
||||||
|
lambda: session.commit(),
|
||||||
|
sql="COMMIT",
|
||||||
|
stat_dates=queued,
|
||||||
|
settings_hash=settings_hash,
|
||||||
|
)
|
||||||
return queued
|
return queued
|
||||||
|
|
||||||
|
|
||||||
@@ -1722,7 +1871,22 @@ async def _refresh_daily_leaderboard_snapshot(
|
|||||||
include_failed: bool,
|
include_failed: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
acquired = False
|
acquired = False
|
||||||
|
trace_token = _LEADERBOARD_TRACE_CONTEXT.set(
|
||||||
|
{
|
||||||
|
"job_key": job_key,
|
||||||
|
"stat_date": stat_date,
|
||||||
|
"settings_hash": settings_hash,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
snapshot_start = perf_counter()
|
||||||
try:
|
try:
|
||||||
|
logger.info(
|
||||||
|
"Leaderboard snapshot build start: job=%s stat_date=%s settings_hash=%s started_at=%s",
|
||||||
|
job_key,
|
||||||
|
stat_date,
|
||||||
|
settings_hash,
|
||||||
|
datetime.now(BEIJING_TZ).isoformat(),
|
||||||
|
)
|
||||||
if _LEADERBOARD_START_DELAY_SECONDS > 0:
|
if _LEADERBOARD_START_DELAY_SECONDS > 0:
|
||||||
await asyncio.sleep(_LEADERBOARD_START_DELAY_SECONDS)
|
await asyncio.sleep(_LEADERBOARD_START_DELAY_SECONDS)
|
||||||
await _store_daily_snapshot(
|
await _store_daily_snapshot(
|
||||||
@@ -1758,6 +1922,14 @@ async def _refresh_daily_leaderboard_snapshot(
|
|||||||
error=None,
|
error=None,
|
||||||
generated_at=generated_at,
|
generated_at=generated_at,
|
||||||
)
|
)
|
||||||
|
logger.info(
|
||||||
|
"Leaderboard snapshot build done: job=%s stat_date=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
|
||||||
|
job_key,
|
||||||
|
stat_date,
|
||||||
|
settings_hash,
|
||||||
|
datetime.now(BEIJING_TZ).isoformat(),
|
||||||
|
(perf_counter() - snapshot_start) * 1000,
|
||||||
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.warning("Daily leaderboard snapshot timed out: %s", job_key)
|
logger.warning("Daily leaderboard snapshot timed out: %s", job_key)
|
||||||
await _store_daily_snapshot(
|
await _store_daily_snapshot(
|
||||||
@@ -1781,6 +1953,7 @@ async def _refresh_daily_leaderboard_snapshot(
|
|||||||
finally:
|
finally:
|
||||||
if acquired:
|
if acquired:
|
||||||
_leaderboard_semaphore.release()
|
_leaderboard_semaphore.release()
|
||||||
|
_LEADERBOARD_TRACE_CONTEXT.reset(trace_token)
|
||||||
|
|
||||||
|
|
||||||
async def _load_or_schedule_daily_leaderboard(
|
async def _load_or_schedule_daily_leaderboard(
|
||||||
@@ -2248,22 +2421,182 @@ class AdminUserQaPageResponse(BaseModel):
|
|||||||
items: list[AdminUserQaItem] = Field(default_factory=list)
|
items: list[AdminUserQaItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}/qa", response_model=AdminUserQaPageResponse)
|
def _qa_sort_datetime(value: str | None) -> datetime:
|
||||||
async def list_user_qa(
|
if not value:
|
||||||
user_id: str,
|
return datetime.min.replace(tzinfo=BEIJING_TZ)
|
||||||
request: Request,
|
text_value = str(value).strip()
|
||||||
page: int = Query(default=1, ge=1),
|
if text_value.endswith("Z"):
|
||||||
page_size: int = Query(default=20, ge=1, le=100),
|
text_value = text_value[:-1] + "+00:00"
|
||||||
q: str | None = Query(default=None, description="Search question, answer, run id, or thread id"),
|
try:
|
||||||
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
|
parsed = datetime.fromisoformat(text_value)
|
||||||
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
|
except ValueError:
|
||||||
) -> AdminUserQaPageResponse:
|
return datetime.min.replace(tzinfo=BEIJING_TZ)
|
||||||
"""Return one user's Q&A runs from lightweight run summary fields."""
|
if parsed.tzinfo is None:
|
||||||
await _require_admin(request)
|
return parsed.replace(tzinfo=BEIJING_TZ)
|
||||||
session_factory = get_session_factory()
|
return parsed.astimezone(BEIJING_TZ)
|
||||||
if session_factory is None:
|
|
||||||
raise HTTPException(status_code=503, detail="Q&A audit requires database persistence")
|
|
||||||
|
|
||||||
|
|
||||||
|
def _qa_dedupe_key(item: AdminUserQaItem) -> tuple[str, str, str]:
|
||||||
|
question_key = _question_similarity_key(item.question)[:160]
|
||||||
|
answer_key = _question_similarity_key(item.answer)[:160]
|
||||||
|
return (item.thread_id, question_key, answer_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _message_value(message: Any, key: str, default: Any = None) -> Any:
|
||||||
|
if isinstance(message, dict):
|
||||||
|
return message.get(key, default)
|
||||||
|
return getattr(message, key, default)
|
||||||
|
|
||||||
|
|
||||||
|
def _qa_message_type(message: Any) -> str:
|
||||||
|
value = _message_value(message, "type") or _message_value(message, "role") or ""
|
||||||
|
if value == "user":
|
||||||
|
return "human"
|
||||||
|
if value == "assistant":
|
||||||
|
return "ai"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _qa_message_id(message: Any, fallback: str) -> str:
|
||||||
|
return str(_message_value(message, "id") or fallback)
|
||||||
|
|
||||||
|
|
||||||
|
def _qa_message_text(message: Any) -> str:
|
||||||
|
return _message_content_to_text(_message_value(message, "content")).strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_thread_checkpoint_messages(checkpointer, thread_id: str) -> list[dict]:
|
||||||
|
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||||
|
try:
|
||||||
|
checkpoint_tuple = await checkpointer.aget_tuple(config)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to read checkpoint while listing Q&A: thread=%s", thread_id, exc_info=True)
|
||||||
|
return []
|
||||||
|
if checkpoint_tuple is None:
|
||||||
|
return []
|
||||||
|
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
|
||||||
|
channel_values = checkpoint.get("channel_values", {}) if isinstance(checkpoint, dict) else {}
|
||||||
|
values = serialize_channel_values(channel_values)
|
||||||
|
messages = values.get("messages", [])
|
||||||
|
return messages if isinstance(messages, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _qa_items_from_checkpoint_messages(
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
thread_id: str,
|
||||||
|
thread_title: str,
|
||||||
|
assistant_id: str,
|
||||||
|
thread_time: str,
|
||||||
|
messages: list[dict],
|
||||||
|
) -> list[AdminUserQaItem]:
|
||||||
|
items: list[AdminUserQaItem] = []
|
||||||
|
current_question = ""
|
||||||
|
current_answer = ""
|
||||||
|
current_id = ""
|
||||||
|
current_index = 0
|
||||||
|
|
||||||
|
def flush() -> None:
|
||||||
|
nonlocal current_question, current_answer, current_id, current_index
|
||||||
|
question = _clean_question_text(current_question)
|
||||||
|
if not _is_displayable_question(question):
|
||||||
|
current_question = ""
|
||||||
|
current_answer = ""
|
||||||
|
current_id = ""
|
||||||
|
return
|
||||||
|
items.append(
|
||||||
|
AdminUserQaItem(
|
||||||
|
run_id=current_id or f"{thread_id}:checkpoint:{current_index}",
|
||||||
|
thread_id=thread_id,
|
||||||
|
thread_title=thread_title,
|
||||||
|
assistant_id=assistant_id,
|
||||||
|
status="checkpoint",
|
||||||
|
question=question[:2000],
|
||||||
|
answer=current_answer.strip()[:6000],
|
||||||
|
created_at=thread_time,
|
||||||
|
updated_at=thread_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
current_question = ""
|
||||||
|
current_answer = ""
|
||||||
|
current_id = ""
|
||||||
|
|
||||||
|
for index, message in enumerate(messages):
|
||||||
|
msg_type = _qa_message_type(message)
|
||||||
|
if msg_type == "human":
|
||||||
|
if current_question:
|
||||||
|
flush()
|
||||||
|
current_question = _qa_message_text(message)
|
||||||
|
current_answer = ""
|
||||||
|
current_id = _qa_message_id(message, f"{thread_id}:checkpoint:{index}")
|
||||||
|
current_index = index
|
||||||
|
continue
|
||||||
|
if msg_type == "ai" and current_question:
|
||||||
|
text = _qa_message_text(message)
|
||||||
|
if text:
|
||||||
|
current_answer = text
|
||||||
|
|
||||||
|
if current_question:
|
||||||
|
flush()
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_thread_checkpoint_qa_items(
|
||||||
|
*,
|
||||||
|
request: Request,
|
||||||
|
user_id: str,
|
||||||
|
) -> list[AdminUserQaItem]:
|
||||||
|
repo = get_thread_store(request)
|
||||||
|
checkpointer = get_checkpointer(request)
|
||||||
|
items: list[AdminUserQaItem] = []
|
||||||
|
offset = 0
|
||||||
|
page_size = 200
|
||||||
|
while True:
|
||||||
|
rows = await repo.search(limit=page_size, offset=offset, user_id=user_id)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
for row in rows:
|
||||||
|
thread_id = str(row.get("thread_id") or "")
|
||||||
|
if not thread_id:
|
||||||
|
continue
|
||||||
|
messages = await _read_thread_checkpoint_messages(checkpointer, thread_id)
|
||||||
|
if not messages:
|
||||||
|
continue
|
||||||
|
metadata = row.get("metadata") if isinstance(row.get("metadata"), dict) else {}
|
||||||
|
context = row.get("context") if isinstance(row.get("context"), dict) else {}
|
||||||
|
assistant_id = str(
|
||||||
|
row.get("assistant_id")
|
||||||
|
or (context or {}).get("agent_id")
|
||||||
|
or (metadata or {}).get("agent_id")
|
||||||
|
or (context or {}).get("agent_name")
|
||||||
|
or (metadata or {}).get("agent_name")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
thread_time = str(row.get("updated_at") or row.get("created_at") or "")
|
||||||
|
items.extend(
|
||||||
|
_qa_items_from_checkpoint_messages(
|
||||||
|
user_id=user_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
thread_title=str(row.get("display_name") or ""),
|
||||||
|
assistant_id=assistant_id,
|
||||||
|
thread_time=thread_time,
|
||||||
|
messages=messages,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(rows) < page_size:
|
||||||
|
break
|
||||||
|
offset += len(rows)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_run_summary_qa_items(
|
||||||
|
*,
|
||||||
|
session_factory,
|
||||||
|
user_id: str,
|
||||||
|
since: str | None,
|
||||||
|
until: str | None,
|
||||||
|
) -> list[AdminUserQaItem]:
|
||||||
filters = [
|
filters = [
|
||||||
RunRow.user_id == user_id,
|
RunRow.user_id == user_id,
|
||||||
RunRow.first_human_message.is_not(None),
|
RunRow.first_human_message.is_not(None),
|
||||||
@@ -2272,26 +2605,9 @@ async def list_user_qa(
|
|||||||
if since or until:
|
if since or until:
|
||||||
since_dt, until_dt, _range_days = _resolve_range(365, since, until)
|
since_dt, until_dt, _range_days = _resolve_range(365, since, until)
|
||||||
filters.append(_run_time_filter(since_dt, until_dt))
|
filters.append(_run_time_filter(since_dt, until_dt))
|
||||||
needle = (q or "").strip()
|
|
||||||
if needle:
|
|
||||||
like = f"%{needle}%"
|
|
||||||
filters.append(
|
|
||||||
or_(
|
|
||||||
RunRow.first_human_message.like(like),
|
|
||||||
RunRow.last_ai_message.like(like),
|
|
||||||
RunRow.run_id.like(like),
|
|
||||||
RunRow.thread_id.like(like),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
offset = (page - 1) * page_size
|
|
||||||
async with session_factory() as session:
|
async with session_factory() as session:
|
||||||
await _set_analytics_read_timeout(session)
|
await _set_analytics_read_timeout(session)
|
||||||
total = (
|
|
||||||
await session.execute(
|
|
||||||
select(func.count(RunRow.run_id)).where(*filters)
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
rows = (
|
rows = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(
|
select(
|
||||||
@@ -2316,32 +2632,152 @@ async def list_user_qa(
|
|||||||
)
|
)
|
||||||
.where(*filters)
|
.where(*filters)
|
||||||
.order_by(RunRow.created_at.desc(), RunRow.run_id.desc())
|
.order_by(RunRow.created_at.desc(), RunRow.run_id.desc())
|
||||||
.limit(page_size)
|
|
||||||
.offset(offset)
|
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
AdminUserQaItem(
|
||||||
|
run_id=r[0] or "",
|
||||||
|
thread_id=r[1] or "",
|
||||||
|
thread_title=r[2] or "",
|
||||||
|
assistant_id=r[3] or "",
|
||||||
|
status=r[4] or "",
|
||||||
|
model_name=r[5] or "",
|
||||||
|
question=_clean_question_text(r[6])[:2000],
|
||||||
|
answer=str(r[7] or "").strip()[:6000],
|
||||||
|
total_tokens=int(r[8] or 0),
|
||||||
|
created_at=_iso(r[9]),
|
||||||
|
updated_at=_iso(r[10]),
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
if _is_displayable_question(r[6])
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_qa_items(
|
||||||
|
items: list[AdminUserQaItem],
|
||||||
|
*,
|
||||||
|
q: str | None,
|
||||||
|
since: str | None,
|
||||||
|
until: str | None,
|
||||||
|
) -> list[AdminUserQaItem]:
|
||||||
|
needle = (q or "").strip().lower()
|
||||||
|
since_dt = _parse_optional_datetime(since) if since else None
|
||||||
|
until_dt = _parse_optional_datetime(until) if until else None
|
||||||
|
filtered: list[AdminUserQaItem] = []
|
||||||
|
for item in items:
|
||||||
|
item_dt = _qa_sort_datetime(item.created_at or item.updated_at)
|
||||||
|
if since_dt and item_dt < since_dt:
|
||||||
|
continue
|
||||||
|
if until_dt and item_dt > until_dt:
|
||||||
|
continue
|
||||||
|
if needle:
|
||||||
|
haystack = "\n".join(
|
||||||
|
[
|
||||||
|
item.question,
|
||||||
|
item.answer,
|
||||||
|
item.run_id,
|
||||||
|
item.thread_id,
|
||||||
|
item.thread_title,
|
||||||
|
item.assistant_id,
|
||||||
|
]
|
||||||
|
).lower()
|
||||||
|
if needle not in haystack:
|
||||||
|
continue
|
||||||
|
filtered.append(item)
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_user_qa_page(
|
||||||
|
*,
|
||||||
|
request: Request,
|
||||||
|
user_id: str,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
q: str | None,
|
||||||
|
since: str | None,
|
||||||
|
until: str | None,
|
||||||
|
include_checkpoints: bool = True,
|
||||||
|
) -> AdminUserQaPageResponse:
|
||||||
|
session_factory = get_session_factory()
|
||||||
|
if session_factory is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Q&A audit requires database persistence")
|
||||||
|
|
||||||
|
items = await _list_run_summary_qa_items(
|
||||||
|
session_factory=session_factory,
|
||||||
|
user_id=user_id,
|
||||||
|
since=since,
|
||||||
|
until=until,
|
||||||
|
)
|
||||||
|
if include_checkpoints:
|
||||||
|
items.extend(await _list_thread_checkpoint_qa_items(request=request, user_id=user_id))
|
||||||
|
|
||||||
|
by_key: dict[tuple[str, str, str], AdminUserQaItem] = {}
|
||||||
|
for item in items:
|
||||||
|
key = _qa_dedupe_key(item)
|
||||||
|
existing = by_key.get(key)
|
||||||
|
if existing is None or (existing.status == "checkpoint" and item.status != "checkpoint"):
|
||||||
|
by_key[key] = item
|
||||||
|
|
||||||
|
filtered = _filter_qa_items(list(by_key.values()), q=q, since=since, until=until)
|
||||||
|
filtered.sort(
|
||||||
|
key=lambda item: (_qa_sort_datetime(item.created_at or item.updated_at), item.thread_id, item.run_id),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
offset = (page - 1) * page_size
|
||||||
return AdminUserQaPageResponse(
|
return AdminUserQaPageResponse(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
total=int(total or 0),
|
total=len(filtered),
|
||||||
items=[
|
items=filtered[offset : offset + page_size],
|
||||||
AdminUserQaItem(
|
)
|
||||||
run_id=r[0] or "",
|
|
||||||
thread_id=r[1] or "",
|
|
||||||
thread_title=r[2] or "",
|
@router.get("/{user_id}/qa", response_model=AdminUserQaPageResponse)
|
||||||
assistant_id=r[3] or "",
|
async def list_user_qa(
|
||||||
status=r[4] or "",
|
user_id: str,
|
||||||
model_name=r[5] or "",
|
request: Request,
|
||||||
question=_clean_question_text(r[6])[:2000],
|
page: int = Query(default=1, ge=1),
|
||||||
answer=str(r[7] or "").strip()[:6000],
|
page_size: int = Query(default=20, ge=1, le=100),
|
||||||
total_tokens=int(r[8] or 0),
|
q: str | None = Query(default=None, description="Search question, answer, run id, or thread id"),
|
||||||
created_at=_iso(r[9]),
|
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
|
||||||
updated_at=_iso(r[10]),
|
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
|
||||||
)
|
) -> AdminUserQaPageResponse:
|
||||||
for r in rows
|
"""Return one user's Q&A runs, including older checkpoint-only threads."""
|
||||||
],
|
await _require_admin(request)
|
||||||
|
return await _list_user_qa_page(
|
||||||
|
request=request,
|
||||||
|
user_id=user_id,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
q=q,
|
||||||
|
since=since,
|
||||||
|
until=until,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@self_router.get("/qa", response_model=AdminUserQaPageResponse)
|
||||||
|
async def list_my_qa(
|
||||||
|
request: Request,
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=20, ge=1, le=100),
|
||||||
|
q: str | None = Query(default=None, description="Search question, answer, run id, or thread id"),
|
||||||
|
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
|
||||||
|
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
|
||||||
|
) -> AdminUserQaPageResponse:
|
||||||
|
"""Return the current user's own Q&A history."""
|
||||||
|
user = getattr(request.state, "user", None)
|
||||||
|
if user is None:
|
||||||
|
user = await get_current_user_from_request(request)
|
||||||
|
return await _list_user_qa_page(
|
||||||
|
request=request,
|
||||||
|
user_id=str(user.id),
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
q=q,
|
||||||
|
since=since,
|
||||||
|
until=until,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user