mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-08 20:25:19 +02:00
feat: enhance report generation with error handling and metadata extraction
- Updated the report generation tool to include error handling for failed report generation, persisting error messages in the database. - Modified metadata extraction to count section headings and return a status indicating readiness or failure. - Adjusted frontend components to handle and display error states based on report generation outcomes, improving user feedback and experience.
This commit is contained in:
parent
e4244829ae
commit
90bf93b0a5
3 changed files with 85 additions and 86 deletions
|
|
@ -51,11 +51,8 @@ Write the report now:
|
|||
|
||||
def _extract_metadata(content: str) -> dict[str, Any]:
|
||||
"""Extract metadata from generated Markdown content."""
|
||||
# Extract section headings
|
||||
# Count section headings
|
||||
headings = re.findall(r"^(#{1,6})\s+(.+)$", content, re.MULTILINE)
|
||||
sections = [
|
||||
{"level": len(h[0]), "title": h[1].strip()} for h in headings
|
||||
]
|
||||
|
||||
# Word count
|
||||
word_count = len(content.split())
|
||||
|
|
@ -64,10 +61,10 @@ def _extract_metadata(content: str) -> dict[str, Any]:
|
|||
char_count = len(content)
|
||||
|
||||
return {
|
||||
"sections": sections,
|
||||
"status": "ready",
|
||||
"word_count": word_count,
|
||||
"char_count": char_count,
|
||||
"section_count": len(sections),
|
||||
"section_count": len(headings),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -110,7 +107,7 @@ def create_generate_report_tool(
|
|||
- "Summarize this into a report"
|
||||
|
||||
Args:
|
||||
topic: The main topic or title of the report.
|
||||
topic: A short, concise title for the report (maximum 8 words). Keep it brief and descriptive — e.g. "AI in Healthcare Analysis: A Comprehensive Report" instead of "Comprehensive Analysis of Artificial Intelligence Applications in Modern Healthcare Systems".
|
||||
source_content: The text content to base the report on. This MUST be comprehensive and include:
|
||||
* If discussing the current conversation: a detailed summary of the FULL chat history
|
||||
* If based on knowledge base search: the key findings and insights from search results
|
||||
|
|
@ -127,14 +124,41 @@ def create_generate_report_tool(
|
|||
- word_count: Number of words in the report
|
||||
- message: Status message (or "error" field if failed)
|
||||
"""
|
||||
async def _save_failed_report(error_msg: str) -> int | None:
|
||||
"""Persist a failed report row so the error is visible later."""
|
||||
try:
|
||||
failed_report = Report(
|
||||
title=topic,
|
||||
content=None,
|
||||
report_metadata={
|
||||
"status": "failed",
|
||||
"error_message": error_msg,
|
||||
},
|
||||
report_style=report_style,
|
||||
search_space_id=search_space_id,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
db_session.add(failed_report)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(failed_report)
|
||||
logger.info(
|
||||
f"[generate_report] Saved failed report {failed_report.id}: {error_msg}"
|
||||
)
|
||||
return failed_report.id
|
||||
except Exception:
|
||||
logger.exception("[generate_report] Could not persist failed report row")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Get the LLM instance for this search space
|
||||
llm = await get_document_summary_llm(db_session, search_space_id)
|
||||
if not llm:
|
||||
error_msg = "No LLM configured. Please configure a language model in Settings."
|
||||
report_id = await _save_failed_report(error_msg)
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": "No LLM configured. Please configure a language model in Settings.",
|
||||
"report_id": None,
|
||||
"error": error_msg,
|
||||
"report_id": report_id,
|
||||
"title": topic,
|
||||
}
|
||||
|
||||
|
|
@ -159,14 +183,16 @@ def create_generate_report_tool(
|
|||
report_content = response.content
|
||||
|
||||
if not report_content or not isinstance(report_content, str):
|
||||
error_msg = "LLM returned empty or invalid content"
|
||||
report_id = await _save_failed_report(error_msg)
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": "LLM returned empty or invalid content",
|
||||
"report_id": None,
|
||||
"error": error_msg,
|
||||
"report_id": report_id,
|
||||
"title": topic,
|
||||
}
|
||||
|
||||
# Extract metadata
|
||||
# Extract metadata (includes "status": "ready")
|
||||
metadata = _extract_metadata(report_content)
|
||||
|
||||
# Save to database
|
||||
|
|
@ -199,11 +225,12 @@ def create_generate_report_tool(
|
|||
except Exception as e:
|
||||
error_message = str(e)
|
||||
logger.exception(f"[generate_report] Error: {error_message}")
|
||||
report_id = await _save_failed_report(error_message)
|
||||
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": error_message,
|
||||
"report_id": None,
|
||||
"report_id": report_id,
|
||||
"title": topic,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,14 +35,8 @@ const ReportContentResponseSchema = z.object({
|
|||
content: z.string().nullish(),
|
||||
report_metadata: z
|
||||
.object({
|
||||
sections: z
|
||||
.array(
|
||||
z.object({
|
||||
level: z.number(),
|
||||
title: z.string(),
|
||||
})
|
||||
)
|
||||
.nullish(),
|
||||
status: z.enum(["ready", "failed"]).nullish(),
|
||||
error_message: z.string().nullish(),
|
||||
word_count: z.number().nullish(),
|
||||
char_count: z.number().nullish(),
|
||||
section_count: z.number().nullish(),
|
||||
|
|
@ -126,7 +120,15 @@ function ReportPanelContent({
|
|||
if (cancelled) return;
|
||||
const parsed = ReportContentResponseSchema.safeParse(rawData);
|
||||
if (parsed.success) {
|
||||
setReportContent(parsed.data);
|
||||
// Check if the report was marked as failed in metadata
|
||||
if (parsed.data.report_metadata?.status === "failed") {
|
||||
setError(
|
||||
parsed.data.report_metadata?.error_message ||
|
||||
"Report generation failed"
|
||||
);
|
||||
} else {
|
||||
setReportContent(parsed.data);
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"Invalid report content response:",
|
||||
|
|
|
|||
|
|
@ -2,12 +2,10 @@
|
|||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
FileTextIcon,
|
||||
Loader2Icon,
|
||||
} from "lucide-react";
|
||||
import { FileTextIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import {
|
||||
openReportPanelAtom,
|
||||
reportPanelAtom,
|
||||
|
|
@ -38,6 +36,8 @@ const ReportMetadataResponseSchema = z.object({
|
|||
title: z.string(),
|
||||
report_metadata: z
|
||||
.object({
|
||||
status: z.enum(["ready", "failed"]).nullish(),
|
||||
error_message: z.string().nullish(),
|
||||
word_count: z.number().nullish(),
|
||||
section_count: z.number().nullish(),
|
||||
})
|
||||
|
|
@ -50,69 +50,24 @@ const ReportMetadataResponseSchema = z.object({
|
|||
type GenerateReportArgs = z.infer<typeof GenerateReportArgsSchema>;
|
||||
type GenerateReportResult = z.infer<typeof GenerateReportResultSchema>;
|
||||
|
||||
/**
|
||||
* Shimmer line used in the skeleton loading state.
|
||||
* Each line has a staggered delay and random-ish width to mimic real paragraphs.
|
||||
*/
|
||||
function ShimmerLine({ width, delay }: { width: string; delay: string }) {
|
||||
return (
|
||||
<div
|
||||
className="h-2.5 rounded-md bg-muted/60"
|
||||
style={{
|
||||
width,
|
||||
animation: `pulse 2s cubic-bezier(0.4,0,0.6,1) infinite`,
|
||||
animationDelay: delay,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading state component shown while report is being generated.
|
||||
* Renders a card with a skeleton that looks like report content being written.
|
||||
* Matches the compact card layout of the completed ReportCard.
|
||||
*/
|
||||
function ReportGeneratingState({ topic }: { topic: string }) {
|
||||
return (
|
||||
<div className="my-4 overflow-hidden rounded-xl border bg-card">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 sm:gap-3 border-b bg-muted/30 px-4 py-3 sm:px-6 sm:py-4">
|
||||
<div className="flex w-full items-center gap-2 sm:gap-3 bg-muted/30 px-4 py-3 sm:px-6 sm:py-4">
|
||||
<div className="flex size-8 sm:size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<FileTextIcon className="size-4 sm:size-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight truncate">
|
||||
{topic}
|
||||
</h3>
|
||||
<div className="mt-1 flex items-center gap-1.5 text-muted-foreground">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
<span className="text-[11px] sm:text-xs">Writing report…</span>
|
||||
</div>
|
||||
<TextShimmerLoader text="Writing report…" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skeleton body – simulates paragraphs being written */}
|
||||
<div className="px-4 py-4 sm:px-6 sm:py-5 space-y-4 max-h-52 overflow-hidden relative">
|
||||
{/* "Heading" */}
|
||||
<ShimmerLine width="40%" delay="0ms" />
|
||||
|
||||
{/* Paragraph 1 */}
|
||||
<div className="space-y-2">
|
||||
<ShimmerLine width="100%" delay="100ms" />
|
||||
<ShimmerLine width="92%" delay="150ms" />
|
||||
<ShimmerLine width="97%" delay="200ms" />
|
||||
<ShimmerLine width="60%" delay="250ms" />
|
||||
</div>
|
||||
|
||||
{/* "Heading 2" */}
|
||||
<ShimmerLine width="35%" delay="300ms" />
|
||||
|
||||
{/* Paragraph 2 */}
|
||||
<div className="space-y-2">
|
||||
<ShimmerLine width="95%" delay="350ms" />
|
||||
<ShimmerLine width="100%" delay="400ms" />
|
||||
<ShimmerLine width="88%" delay="450ms" />
|
||||
</div>
|
||||
|
||||
{/* Bottom fade-out */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-card to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -125,7 +80,7 @@ function ReportErrorState({ title, error }: { title: string; error: string }) {
|
|||
<div className="my-4 overflow-hidden rounded-xl border bg-card">
|
||||
<div className="flex items-center gap-2 sm:gap-3 bg-muted/30 px-4 py-3 sm:px-6 sm:py-4">
|
||||
<div className="flex size-8 sm:size-10 shrink-0 items-center justify-center rounded-lg bg-muted/60">
|
||||
<FileTextIcon className="size-4 sm:size-5 text-muted-foreground/50" />
|
||||
<FileTextIcon className="size-4 sm:size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-muted-foreground text-sm sm:text-base leading-tight truncate">
|
||||
|
|
@ -161,12 +116,14 @@ function ReportCard({
|
|||
sectionCount: number | null;
|
||||
}>({ title, wordCount: wordCount ?? null, sectionCount: null });
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch lightweight metadata (title + counts only, no content)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchMetadata = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const rawData = await baseApiService.get<unknown>(
|
||||
`/api/v1/reports/${reportId}/content`
|
||||
|
|
@ -174,16 +131,24 @@ function ReportCard({
|
|||
if (cancelled) return;
|
||||
const parsed = ReportMetadataResponseSchema.safeParse(rawData);
|
||||
if (parsed.success) {
|
||||
setMetadata({
|
||||
title: parsed.data.title || title,
|
||||
wordCount:
|
||||
parsed.data.report_metadata?.word_count ?? wordCount ?? null,
|
||||
sectionCount:
|
||||
parsed.data.report_metadata?.section_count ?? null,
|
||||
});
|
||||
// Check if report was marked as failed in metadata
|
||||
if (parsed.data.report_metadata?.status === "failed") {
|
||||
setError(
|
||||
parsed.data.report_metadata?.error_message ||
|
||||
"Report generation failed"
|
||||
);
|
||||
} else {
|
||||
setMetadata({
|
||||
title: parsed.data.title || title,
|
||||
wordCount:
|
||||
parsed.data.report_metadata?.word_count ?? wordCount ?? null,
|
||||
sectionCount:
|
||||
parsed.data.report_metadata?.section_count ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — we already have the title and word count from the tool result
|
||||
if (!cancelled) setError("No report found");
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
|
|
@ -194,6 +159,11 @@ function ReportCard({
|
|||
};
|
||||
}, [reportId, title, wordCount]);
|
||||
|
||||
// Show non-clickable error card for any error (failed status, not found, etc.)
|
||||
if (!isLoading && error) {
|
||||
return <ReportErrorState title={title} error={error} />;
|
||||
}
|
||||
|
||||
const isActive = panelState.isOpen && panelState.reportId === reportId;
|
||||
|
||||
const handleOpen = () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue