diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index 14dfe1b2e..32a484fce 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -23,6 +23,8 @@ Today's date (UTC): {resolved_today} When writing mathematical formulas or equations, ALWAYS use LaTeX notation. NEVER use backtick code spans or Unicode symbols for math. +NEVER expose internal tool parameter names, backend IDs, or implementation details to the user. Always use natural, user-friendly language instead. + """ @@ -37,6 +39,8 @@ Today's date (UTC): {resolved_today} When writing mathematical formulas or equations, ALWAYS use LaTeX notation. NEVER use backtick code spans or Unicode symbols for math. +NEVER expose internal tool parameter names, backend IDs, or implementation details to the user. Always use natural, user-friendly language instead. + """ @@ -96,41 +100,43 @@ You have access to the following tools: - IMPORTANT: Only one podcast can be generated at a time. If a podcast is already being generated, the tool will return status "already_generating". - After calling this tool, inform the user that podcast generation has started and they will see the player when it's ready (takes 3-5 minutes). -3. generate_report: Generate a structured Markdown report from provided content. - - Use this when the user asks to create, generate, write, produce, draft, or summarize into a report-style deliverable. - - DECISION RULE (HIGH PRIORITY): If the user asks for a report in any form, call `generate_report` instead of writing the full report directly in chat. - - Only skip `generate_report` if the user explicitly asks for chat-only output (e.g., "just answer in chat", "no report card", "don't generate a report"). - - Trigger classes include: - * Direct trigger words: report, document, memo, letter, template - * Creation-intent phrases: "write a document/report/post/article" - * File-intent words: requests containing "save", "file", or "document" when intent is to create a report-like deliverable - * Word-doc specific triggers: professional report-style deliverable, professional document, Word doc, .docx - * Other report-like output intents: one-pager, blog post, article, standalone written content, comprehensive guide - * General artifact-style intents: analysis / writing as substantial deliverables - - Trigger phrases include: - * "generate a report about", "write a report", "produce a report" - * "create a detailed report about", "make a research report on" - * "summarize this into a report", "turn this into a report" - * "write a report/document", "draft a report" - * "create an executive summary", "make a briefing note", "write a one-pager" - * "write a blog post", "write an article", "create a comprehensive guide" - * "create a small report", "write a short report", "make a quick report", "brief report for class" +3. generate_report: Generate or revise a structured Markdown report artifact. + - WHEN TO CALL THIS TOOL — the message must contain a creation or modification VERB directed at producing a deliverable: + * Creation verbs: write, create, generate, draft, produce, summarize into, turn into, make + * Modification verbs: revise, update, expand, add (a section), rewrite, make (it shorter/longer/formal) + * Example triggers: "generate a report about...", "write a document on...", "add a section about budget", "make the report shorter", "rewrite in formal tone" + - WHEN NOT TO CALL THIS TOOL (answer in chat instead): + * Questions or discussion about the report: "What can we add?", "What's missing?", "Is the data accurate?", "How could this be improved?" + * Suggestions or brainstorming: "What other topics could be covered?", "What else could be added?", "What would make this better?" + * Asking for explanations: "Can you explain section 2?", "Why did you include that?", "What does this part mean?" + * Quick follow-ups or critiques: "Is the conclusion strong enough?", "Are there any gaps?", "What about the competitors?" + * THE TEST: Does the message contain a creation/modification VERB (from the list above) directed at producing or changing a deliverable? If NO verb → answer conversationally in chat. Do NOT assume the user wants a revision just because a report exists in the conversation. - IMPORTANT FORMAT RULE: Reports are ALWAYS generated in Markdown. - Args: - - topic: The main topic or title of the report - - source_content: The text content to base the report on. This MUST be comprehensive and include: - * If discussing the current conversation: Include a detailed summary of the FULL chat history (all user questions and your responses) - * If based on knowledge base search: Include the key findings and insights from the search results - * You can combine both: conversation context + search results for richer reports - * The more detailed the source_content, the better the report quality - - report_style: Optional style. Options: "detailed" (default), "executive_summary", "deep_research", "brief" - - user_instructions: Optional specific instructions (e.g., "focus on financial impacts", "include recommendations") + - topic: Short title for the report (max ~8 words). + - source_content: The text content to base the report on. + * For source_strategy="conversation" or "provided": Include a comprehensive summary of the relevant content. + * For source_strategy="kb_search": Can be empty or minimal — the tool handles searching internally. + * For source_strategy="auto": Include what you have; the tool searches KB if it's not enough. + - source_strategy: Controls how the tool collects source material. One of: + * "conversation" — The conversation already contains enough context (prior Q&A, discussion, pasted text, scraped pages). Pass a thorough summary as source_content. Do NOT call search_knowledge_base separately. + * "kb_search" — The tool will search the knowledge base internally. Provide search_queries with 1-5 targeted queries. Do NOT call search_knowledge_base separately. + * "auto" — Use source_content if sufficient, otherwise fall back to internal KB search using search_queries. + * "provided" — Use only what is in source_content (default, backward-compatible). + - search_queries: When source_strategy is "kb_search" or "auto", provide 1-5 specific search queries for the knowledge base. These should be precise, not just the topic name repeated. + - report_style: Controls report depth. Options: "detailed" (DEFAULT), "deep_research", "brief". + Use "brief" ONLY when the user explicitly asks for a short/concise/one-page report (e.g., "one page", "keep it short", "brief report", "500 words"). Default to "detailed" for all other requests. + - user_instructions: Optional specific instructions (e.g., "focus on financial impacts", "include recommendations"). When revising (parent_report_id set), describe WHAT TO CHANGE. If the user mentions a length preference (e.g., "one page", "500 words", "2 pages"), include that VERBATIM here AND set report_style="brief". + - parent_report_id: Set this to the report_id from a previous generate_report result when the user wants to MODIFY an existing report. Do NOT set it for new reports or questions about reports. - Returns: A dictionary with status "ready" or "failed", report_id, title, and word_count. - The report is generated immediately in Markdown and displayed inline in the chat. - Export/download formats (e.g., PDF/DOCX) are produced from the generated Markdown report. - - SOURCE-COLLECTION RULE: - * If the user already provided enough source material (current chat content, uploaded files, pasted text, or a summarized video/article), generate the report directly from that. - * Use search_knowledge_base first when additional context is needed or the user asks for information beyond what is already available in the conversation. + - SOURCE STRATEGY DECISION (HIGH PRIORITY — follow this exactly): + * If the conversation already has substantive Q&A / discussion on the topic → use source_strategy="conversation" with a comprehensive summary as source_content. Do NOT call search_knowledge_base first. + * If the user wants a report on a topic not yet discussed → use source_strategy="kb_search" with targeted search_queries. Do NOT call search_knowledge_base first. + * If you have some content but might need more → use source_strategy="auto" with both source_content and search_queries. + * When revising an existing report (parent_report_id set) and the conversation has relevant context → use source_strategy="conversation". The revision will use the previous report content plus your source_content. + * NEVER call search_knowledge_base and then pass its results to generate_report. The tool handles KB search internally. - AFTER CALLING THIS TOOL: Do NOT repeat, summarize, or reproduce the report content in the chat. The report is already displayed as an interactive card that the user can open, read, copy, and export. Simply confirm that the report was generated (e.g., "I've generated your report on [topic]. You can view the Markdown report now, and export to PDF/DOCX from the card."). NEVER write out the report text in the chat. 4. link_preview: Fetch metadata for a URL to display a rich preview card. @@ -363,15 +369,36 @@ _TOOLS_INSTRUCTIONS_EXAMPLES_COMMON = """ - Then: `generate_podcast(source_content="Key insights about quantum computing from the knowledge base:\\n\\n[Comprehensive summary of all relevant search results with key facts, concepts, and findings]", podcast_title="Quantum Computing Explained")` - User: "Generate a report about AI trends" - - First search: `search_knowledge_base(query="AI trends")` - - Then: `generate_report(topic="AI Trends Report", source_content="Key insights about AI trends from the knowledge base:\\n\\n[Comprehensive summary of all relevant search results with key facts, concepts, and findings]", report_style="detailed")` + - Call: `generate_report(topic="AI Trends Report", source_strategy="kb_search", search_queries=["AI trends recent developments", "artificial intelligence industry trends", "AI market growth and predictions"], report_style="detailed")` + - WHY: Has creation verb "generate" → call the tool. No prior discussion → use kb_search. - User: "Write a research report from this conversation" - - Call: `generate_report(topic="Research Report", source_content="Complete conversation summary:\\n\\nUser asked about [topic 1]:\\n[Your detailed response]\\n\\nUser then asked about [topic 2]:\\n[Your detailed response]\\n\\n[Continue for all exchanges in the conversation]", report_style="deep_research")` + - Call: `generate_report(topic="Research Report", source_strategy="conversation", source_content="Complete conversation summary:\\n\\nUser asked about [topic 1]:\\n[Your detailed response]\\n\\nUser then asked about [topic 2]:\\n[Your detailed response]\\n\\n[Continue for all exchanges in the conversation]", report_style="deep_research")` + - WHY: Has creation verb "write" → call the tool. Conversation has the content → use source_strategy="conversation". - User: "Create a brief executive summary about our project progress" - - First search: `search_knowledge_base(query="project progress updates")` - - Then: `generate_report(topic="Project Progress Executive Summary", source_content="[Combined search results and conversation context]", report_style="executive_summary", user_instructions="Focus on milestones achieved and upcoming deadlines")` + - Call: `generate_report(topic="Project Progress Executive Summary", source_strategy="kb_search", search_queries=["project progress updates", "project milestones completed", "upcoming project deadlines"], report_style="executive_summary", user_instructions="Focus on milestones achieved and upcoming deadlines")` + - WHY: Has creation verb "create" → call the tool. New topic → use kb_search. + +- User: (after extensive Q&A about React performance) "Turn this into a report" + - Call: `generate_report(topic="React Performance Optimization Guide", source_strategy="conversation", source_content="[Thorough summary of all Q&A from this conversation about React performance...]", report_style="detailed")` + - WHY: Has creation verb "turn into" → call the tool. Conversation has the content → use source_strategy="conversation". + +- User: (after a report on Climate Change was generated) "Add a section about carbon capture technologies" + - Call: `generate_report(topic="Climate Crisis: Causes, Impacts, and Solutions", source_strategy="conversation", source_content="[summary of conversation context if any]", parent_report_id=, user_instructions="Add a new section about carbon capture technologies")` + - WHY: Has modification verb "add" + specific deliverable target → call the tool with parent_report_id. Use source_strategy="conversation" since the report already exists. + +- User: (after a report was generated) "What else could we add to have more depth?" + - Do NOT call generate_report. Answer in chat with suggestions, e.g.: "Here are some areas we could expand: 1. ... 2. ... 3. ... Would you like me to add any of these to the report?" + - WHY: No creation/modification verb directed at producing a deliverable. This is a question asking for suggestions. + +- User: (after a report was generated) "Is the conclusion strong enough?" + - Do NOT call generate_report. Answer in chat, e.g.: "The conclusion covers X and Y well, but could be strengthened by adding Z. Want me to revise it?" + - WHY: This is a question/critique, not a modification request. + +- User: (after a report was generated) "What's missing from this report?" + - Do NOT call generate_report. Answer in chat with analysis of gaps. + - WHY: This is a question. The user is asking you to identify gaps, not to fix them yet. - User: "Check out https://dev.to/some-article" - Call: `link_preview(url="https://dev.to/some-article")` diff --git a/surfsense_backend/app/agents/new_chat/tools/registry.py b/surfsense_backend/app/agents/new_chat/tools/registry.py index db48276bc..4c6345bc3 100644 --- a/surfsense_backend/app/agents/new_chat/tools/registry.py +++ b/surfsense_backend/app/agents/new_chat/tools/registry.py @@ -130,14 +130,20 @@ BUILTIN_TOOLS: list[ToolDefinition] = [ requires=["search_space_id", "db_session", "thread_id"], ), # Report generation tool (inline, short-lived sessions for DB ops) + # Supports internal KB search via source_strategy so the agent doesn't + # need to call search_knowledge_base separately before generating. ToolDefinition( name="generate_report", description="Generate a structured Markdown report from provided content", factory=lambda deps: create_generate_report_tool( search_space_id=deps["search_space_id"], thread_id=deps["thread_id"], + connector_service=deps.get("connector_service"), + available_connectors=deps.get("available_connectors"), ), requires=["search_space_id", "thread_id"], + # connector_service and available_connectors are optional — + # when missing, source_strategy="kb_search" degrades gracefully to "provided" ), # Link preview tool - fetches Open Graph metadata for URLs ToolDefinition( diff --git a/surfsense_backend/app/agents/new_chat/tools/report.py b/surfsense_backend/app/agents/new_chat/tools/report.py index 75f087fa2..0896fea4b 100644 --- a/surfsense_backend/app/agents/new_chat/tools/report.py +++ b/surfsense_backend/app/agents/new_chat/tools/report.py @@ -10,29 +10,67 @@ Uses short-lived database sessions to avoid holding connections during long LLM calls (30-120+ seconds). Each DB operation (read config, save report) opens and closes its own session, ensuring no connection is held idle during the LLM API call. + +Generation strategies: + - Single-shot generation for all new reports + - Section-level revision for targeted edits (preserves unchanged sections) + - Full-document revision as fallback for global changes + +Source strategies (how source content is collected): + - "provided" — Use only the supplied source_content (default, backward-compat) + - "conversation" — Same as "provided"; agent passes conversation summary + - "kb_search" — Tool searches knowledge base internally with targeted queries + - "auto" — Use source_content if sufficient, else search KB as fallback """ +import asyncio +import json import logging import re from typing import Any +from langchain_core.callbacks import dispatch_custom_event from langchain_core.messages import HumanMessage from langchain_core.tools import tool from app.db import Report, async_session_maker +from app.services.connector_service import ConnectorService from app.services.llm_service import get_document_summary_llm logger = logging.getLogger(__name__) -# Prompt template for report generation (new report from scratch) -_REPORT_PROMPT = """You are an expert report writer. Generate a well-structured, comprehensive Markdown report based on the provided information. +# ─── Shared Formatting Rules ──────────────────────────────────────────────── +# Reusable formatting instructions appended to section-level and review prompts. + +_FORMATTING_RULES = """\ +- IMPORTANT: Output raw Markdown directly. Do NOT wrap the entire output in a \ +code fence (e.g. ```markdown, ````markdown, or any backtick fence). Individual \ +code examples and diagrams inside the report should still use fenced code blocks, \ +but the report itself must NOT be enclosed in one. +- Maintain proper Markdown formatting throughout. +- When including code examples, ALWAYS format them as proper fenced code blocks \ +with the correct language identifier (e.g. ```java, ```python). Code inside code \ +blocks MUST have proper line breaks and indentation — NEVER put multiple statements \ +on a single line. Each statement, brace, and logical block must be on its own line \ +with correct indentation. +- When including Mermaid diagrams, use ```mermaid fenced code blocks. Each Mermaid \ +statement MUST be on its own line — NEVER use semicolons to join multiple statements \ +on one line. For line breaks inside node labels, use
(NOT
). +- When including mathematical formulas or equations, ALWAYS use LaTeX notation. \ +NEVER use backtick code spans or Unicode symbols for math.""" + +# ─── Standard Report Footer ───────────────────────────────────────────────── +# Appended to every generated report after content generation. + +_REPORT_FOOTER = "Powered by SurfSense AI." + +# ─── Prompt: Single-Shot Report Generation ─────────────────────────────────── + +_REPORT_PROMPT = """You are an expert report writer. Generate a comprehensive Markdown report. **Topic:** {topic} - **Report Style:** {report_style} - {user_instructions_section} - {previous_version_section} **Source Content:** @@ -40,41 +78,138 @@ _REPORT_PROMPT = """You are an expert report writer. Generate a well-structured, --- -**Instructions:** -1. Write the report in well-formatted Markdown. -2. Include a clear title (as a level-1 heading), an executive summary, and logically organized sections. -3. Use headings (##, ###), bullet points, numbered lists, bold/italic text, and tables where appropriate. -4. Cite specific facts, figures, and findings from the source content. -5. Be thorough and comprehensive — include all relevant information from the source content. -6. End with a conclusion or key takeaways section. -7. The report should be professional and ready to export. -8. When including code examples, ALWAYS format them as proper fenced code blocks with the correct language identifier (e.g. ```java, ```python). Code inside code blocks MUST have proper line breaks and indentation — NEVER put multiple statements on a single line. Each statement, brace, and logical block must be on its own line with correct indentation. -9. When including Mermaid diagrams, use ```mermaid fenced code blocks. Each Mermaid statement MUST be on its own line — NEVER use semicolons to join multiple statements on one line. For line breaks inside node labels, use
(NOT
). Example: - ```mermaid - graph TD - A[Source Code] --> B[Compiler] - B --> C[Bytecode] - ``` -10. When including mathematical formulas or equations, ALWAYS use LaTeX notation. NEVER use backtick code spans or Unicode symbols for math. +{length_instruction} -Write the report now: +Write a well-structured Markdown report with a # title, executive summary, organized sections, and conclusion. Cite facts from the source content. Be thorough and professional. + +{formatting_rules} """ +# ─── Prompt: Full-Document Revision (fallback when section-level fails) ────── + +_REVISION_PROMPT = """You are an expert report editor. Apply ONLY the requested changes — do NOT rewrite from scratch. + +**Topic:** {topic} +**Report Style:** {report_style} +**Modification Instructions:** {user_instructions_section} + +**Source Content (use if relevant):** +{source_content} + +--- + +**EXISTING REPORT:** + +{previous_report_content} + +--- + +{length_instruction} + +Preserve all structure and content not affected by the modification. + +{formatting_rules} +""" + +# ─── Prompt: Section-Level Revision — Identify Affected Sections ───────────── + +_IDENTIFY_SECTIONS_PROMPT = """You are analyzing a Markdown report to determine which sections need modification based on the user's request. + +**User's Modification Request:** {user_instructions} + +**Report Sections (indexed starting at 0):** +{sections_listing} + +--- + +Determine which sections need to be modified, added, or removed to fulfill the user's request. + +Return ONLY a JSON object with these fields: +- "modify": Array of section indices (0-based) that need content changes +- "add": Array of objects like {{"after_index": 2, "heading": "## New Section Title", "description": "What this section should cover"}} for new sections to insert +- "remove": Array of section indices to remove entirely (use sparingly) +- "reasoning": A brief explanation of your decisions + +Guidelines: +- If the change is GLOBAL (e.g., "change the tone", "make the whole report shorter", "translate to Spanish"), include ALL section indices in "modify". +- If the change is TARGETED (e.g., "expand the budget section", "fix the conclusion"), include ONLY the affected section indices. +- For "add a section about X", use the "add" field with the appropriate insertion point. +- Prefer modifying over removing+adding when possible. + +Return ONLY valid JSON, no markdown fences: +""" + +# ─── Prompt: Section-Level Revision — Revise a Single Section ──────────────── + +_REVISE_SECTION_PROMPT = """Revise ONLY this section based on the instructions. If the instructions don't apply, return it UNCHANGED. + +**Modification Instructions:** {user_instructions} + +**Current Section:** +{section_content} + +**Context (surrounding sections — for coherence only, do NOT output them):** +{context_sections} + +**Source Content:** +{source_content} + +--- + +Keep the same heading and heading level. Preserve content not affected by the modification. +{formatting_rules} +""" + +# ─── Prompt: New Section Generation (for section-level add) ───────────────── + +_NEW_SECTION_PROMPT = """You are an expert report writer. Write a new section to be inserted into an existing report. + +**Report Topic:** {topic} +**Report Style:** {report_style} +**Section Heading:** {heading} +**Section Goal:** {description} +**User Instructions:** {user_instructions} + +**Surrounding Context:** +{context_sections} + +**Source Content:** +{source_content} + +--- + +**Rules:** +1. Write ONLY this section, starting with the heading "{heading}". +2. Ensure the section flows naturally with the surrounding context. +3. Be comprehensive — cover the topic described above. +{formatting_rules} + +Write the new section now: +""" + + +# ─── Utility Functions ────────────────────────────────────────────────────── + def _strip_wrapping_code_fences(text: str) -> str: """Remove wrapping code fences that LLMs often add around Markdown output. Handles patterns like: ```markdown\\n...content...\\n``` + ````markdown\\n...content...\\n```` ```md\\n...content...\\n``` ```\\n...content...\\n``` + ```json\\n...content...\\n``` + Supports 3 or more backticks (LLMs escalate when content has triple-backtick blocks). """ stripped = text.strip() - # Match opening fence with optional language tag (markdown, md, or bare) - m = re.match(r"^```(?:markdown|md)?\s*\n", stripped) - if m and stripped.endswith("```"): - stripped = stripped[m.end() :] # remove opening fence - stripped = stripped[:-3].rstrip() # remove closing fence + # Match opening fence with 3+ backticks and optional language tag + m = re.match(r"^(`{3,})(?:markdown|md|json)?\s*\n", stripped) + if m: + fence = m.group(1) # e.g. "```" or "````" + if stripped.endswith(fence): + stripped = stripped[m.end() :] # remove opening fence + stripped = stripped[: -len(fence)].rstrip() # remove closing fence return stripped @@ -97,9 +232,333 @@ def _extract_metadata(content: str) -> dict[str, Any]: } +def _parse_sections(content: str) -> list[dict[str, str]]: + """Parse Markdown content into sections split by # and ## headings. + + Returns a list of dicts: [{"heading": "## Title", "body": "content..."}, ...] + Content before the first heading is captured with heading="". + ### and deeper headings are kept inside their parent ## section's body. + """ + lines = content.split("\n") + sections: list[dict[str, str]] = [] + current_heading = "" + current_body_lines: list[str] = [] + in_code_block = False + + for line in lines: + # Track code blocks to avoid matching headings inside them + stripped = line.strip() + if stripped.startswith("```"): + in_code_block = not in_code_block + + # Only split on # or ## headings (not ### or deeper) and only outside code blocks + is_section_heading = ( + not in_code_block + and re.match(r"^#{1,2}\s+", line) + and not re.match(r"^#{3,}\s+", line) + ) + + if is_section_heading: + # Save previous section + if current_heading or current_body_lines: + sections.append( + { + "heading": current_heading, + "body": "\n".join(current_body_lines).strip(), + } + ) + current_heading = line.strip() + current_body_lines = [] + else: + current_body_lines.append(line) + + # Save last section + if current_heading or current_body_lines: + sections.append( + { + "heading": current_heading, + "body": "\n".join(current_body_lines).strip(), + } + ) + + return sections + + +def _stitch_sections(sections: list[dict[str, str]]) -> str: + """Stitch parsed sections back into a single Markdown string.""" + parts = [] + for section in sections: + if section["heading"]: + parts.append(section["heading"]) + if section["body"]: + parts.append(section["body"]) + return "\n\n".join(parts) + + +# ─── Async Generation Helpers ─────────────────────────────────────────────── + + +async def _revise_with_sections( + llm: Any, + parent_content: str, + user_instructions: str, + source_content: str, + topic: str, + report_style: str, +) -> str | None: + """Section-level revision: identify affected sections and revise only those. + + Unchanged sections are kept byte-for-byte identical. + Returns the revised content, or None to trigger full-document revision fallback. + """ + # Parse report into sections + sections = _parse_sections(parent_content) + if len(sections) < 2: + logger.info( + "[generate_report] Too few sections for section-level revision, using full revision" + ) + return None + + # Build a sections listing for the LLM + sections_listing = "" + for i, sec in enumerate(sections): + heading = sec["heading"] or "(preamble — content before first heading)" + body_preview = ( + sec["body"][:200] + "..." if len(sec["body"]) > 200 else sec["body"] + ) + sections_listing += f"\n[{i}] {heading}\n Preview: {body_preview}\n" + + # Step 1: Ask LLM which sections need modification + identify_prompt = _IDENTIFY_SECTIONS_PROMPT.format( + user_instructions=user_instructions, + sections_listing=sections_listing, + ) + + try: + response = await llm.ainvoke([HumanMessage(content=identify_prompt)]) + raw = response.content + if not raw or not isinstance(raw, str): + return None + + raw = _strip_wrapping_code_fences(raw).strip() + json_match = re.search(r"\{[\s\S]*\}", raw) + if json_match: + raw = json_match.group(0) + + plan = json.loads(raw) + modify_indices: list[int] = plan.get("modify", []) + add_sections: list[dict[str, Any]] = plan.get("add", []) + remove_indices: list[int] = plan.get("remove", []) + reasoning = plan.get("reasoning", "") + + logger.info( + f"[generate_report] Section-level revision plan: " + f"modify={modify_indices}, add={len(add_sections)}, " + f"remove={remove_indices}, reasoning={reasoning}" + ) + except Exception: + logger.warning( + "[generate_report] Failed to identify sections for revision, " + "falling back to full revision", + exc_info=True, + ) + return None + + # If ALL sections need modification, full revision is more efficient and coherent + if len(modify_indices) >= len(sections): + logger.info( + "[generate_report] All sections need modification, deferring to full revision" + ) + return None + + # Compute total operations for progress tracking + total_ops = len(modify_indices) + len(add_sections) + current_op = 0 + + # Emit plan summary + parts = [] + if modify_indices: + parts.append( + f"modifying {len(modify_indices)} section{'s' if len(modify_indices) > 1 else ''}" + ) + if add_sections: + parts.append( + f"adding {len(add_sections)} new section{'s' if len(add_sections) > 1 else ''}" + ) + if remove_indices: + parts.append( + f"removing {len(remove_indices)} section{'s' if len(remove_indices) > 1 else ''}" + ) + plan_summary = ", ".join(parts) if parts else "no changes needed" + + dispatch_custom_event( + "report_progress", + { + "phase": "revision_plan", + "message": plan_summary.capitalize(), + "modify_count": len(modify_indices), + "add_count": len(add_sections), + "remove_count": len(remove_indices), + "total_ops": total_ops, + }, + ) + + # Step 2: Revise only the affected sections + revised_sections = list(sections) # shallow copy — unmodified sections stay as-is + + for idx in modify_indices: + if idx < 0 or idx >= len(sections): + continue + + current_op += 1 + sec = sections[idx] + + # Extract plain section name (strip markdown heading markers) + section_name = ( + re.sub(r"^#+\s*", "", sec["heading"]).strip() + if sec["heading"] + else "Preamble" + ) + dispatch_custom_event( + "report_progress", + { + "phase": "revising_section", + "message": f"Revising: {section_name} ({current_op}/{total_ops})...", + }, + ) + + section_content = ( + f"{sec['heading']}\n\n{sec['body']}" if sec["heading"] else sec["body"] + ) + + # Build context from surrounding sections + context_parts = [] + if idx > 0: + prev = sections[idx - 1] + prev_preview = prev["body"][:300] + ( + "..." if len(prev["body"]) > 300 else "" + ) + context_parts.append( + f"**Previous section:** {prev['heading']}\n{prev_preview}" + ) + if idx < len(sections) - 1: + nxt = sections[idx + 1] + nxt_preview = nxt["body"][:300] + ("..." if len(nxt["body"]) > 300 else "") + context_parts.append(f"**Next section:** {nxt['heading']}\n{nxt_preview}") + context = ( + "\n\n".join(context_parts) if context_parts else "(No surrounding sections)" + ) + + revise_prompt = _REVISE_SECTION_PROMPT.format( + user_instructions=user_instructions, + section_content=section_content, + context_sections=context, + source_content=source_content[:40000], + formatting_rules=_FORMATTING_RULES, + ) + + resp = await llm.ainvoke([HumanMessage(content=revise_prompt)]) + revised_text = resp.content + if revised_text and isinstance(revised_text, str): + revised_text = _strip_wrapping_code_fences(revised_text).strip() + # Parse the LLM output back into heading + body + revised_parsed = _parse_sections(revised_text) + if revised_parsed: + revised_sections[idx] = revised_parsed[0] + else: + revised_sections[idx] = { + "heading": sec["heading"], + "body": revised_text, + } + + logger.info(f"[generate_report] Revised section [{idx}]: {sec['heading']}") + + # Step 3: Handle new section additions (insert in reverse order to preserve indices) + for add_info in sorted( + add_sections, + key=lambda x: x.get("after_index", len(revised_sections) - 1), + reverse=True, + ): + current_op += 1 + after_idx = add_info.get("after_index", len(revised_sections) - 1) + heading = add_info.get("heading", "## New Section") + description = add_info.get("description", "") + + # Extract plain section name for progress display + plain_heading = re.sub(r"^#+\s*", "", heading).strip() + dispatch_custom_event( + "report_progress", + { + "phase": "adding_section", + "message": f"Adding: {plain_heading} ({current_op}/{total_ops})...", + }, + ) + + # Build context from the surrounding sections at the insertion point + ctx_parts = [] + if 0 <= after_idx < len(revised_sections): + before_sec = revised_sections[after_idx] + ctx_parts.append( + f"**Section before:** {before_sec['heading']}\n{before_sec['body'][:300]}" + ) + insert_idx = min(after_idx + 1, len(revised_sections)) + if insert_idx < len(revised_sections): + after_sec = revised_sections[insert_idx] + ctx_parts.append( + f"**Section after:** {after_sec['heading']}\n{after_sec['body'][:300]}" + ) + + new_prompt = _NEW_SECTION_PROMPT.format( + topic=topic, + report_style=report_style, + heading=heading, + description=description, + user_instructions=user_instructions, + context_sections="\n\n".join(ctx_parts) if ctx_parts else "(None)", + source_content=source_content[:30000], + formatting_rules=_FORMATTING_RULES, + ) + + resp = await llm.ainvoke([HumanMessage(content=new_prompt)]) + new_content = resp.content + if new_content and isinstance(new_content, str): + new_content = _strip_wrapping_code_fences(new_content).strip() + new_parsed = _parse_sections(new_content) + if new_parsed: + revised_sections.insert(insert_idx, new_parsed[0]) + else: + revised_sections.insert( + insert_idx, + { + "heading": heading, + "body": new_content, + }, + ) + + logger.info( + f"[generate_report] Added new section after [{after_idx}]: {heading}" + ) + + # Step 4: Handle removals (reverse order to preserve indices) + for idx in sorted(remove_indices, reverse=True): + if 0 <= idx < len(revised_sections): + logger.info( + f"[generate_report] Removed section [{idx}]: " + f"{revised_sections[idx]['heading']}" + ) + revised_sections.pop(idx) + + return _stitch_sections(revised_sections) + + +# ─── Tool Factory ─────────────────────────────────────────────────────────── + + def create_generate_report_tool( search_space_id: int, thread_id: int | None = None, + connector_service: ConnectorService | None = None, + available_connectors: list[str] | None = None, ): """ Factory function to create the generate_report tool with injected dependencies. @@ -110,9 +569,24 @@ def create_generate_report_tool( Uses short-lived database sessions for each DB operation so no connection is held during the long LLM API call. + Generation strategies: + - New reports: single-shot generation (1 LLM call) + - Revisions (targeted edits): section-level (unchanged sections preserved) + - Revisions (global changes): full-document revision fallback + + Source strategies: + - "provided"/"conversation": use only the supplied source_content + - "kb_search": search the knowledge base internally using targeted queries + - "auto": use source_content if sufficient, otherwise fall back to KB search + Args: search_space_id: The user's search space ID thread_id: The chat thread ID for associating the report + connector_service: Optional connector service for internal KB search. + When provided, the tool can search the knowledge base without the + agent having to call search_knowledge_base separately. + available_connectors: Optional list of connector types available in the + search space (used to scope internal KB searches). Returns: A configured tool function for generating reports @@ -121,79 +595,84 @@ def create_generate_report_tool( @tool async def generate_report( topic: str, - source_content: str, + source_content: str = "", + source_strategy: str = "provided", + search_queries: list[str] | None = None, report_style: str = "detailed", user_instructions: str | None = None, parent_report_id: int | None = None, ) -> dict[str, Any]: """ - Generate a structured Markdown report from provided content. + Generate a structured Markdown report artifact from provided content. + + Use this tool when the user asks to create, generate, write, produce, + draft, or summarize into a report-style deliverable. - Use this tool when the user asks to create, generate, write, produce, draft, - or summarize into a report-style deliverable. - HIGH-PRIORITY DECISION RULE: - - If the user asks for a report in any form, - call this tool rather than writing the full report directly in chat. - - Only skip this tool when the user explicitly requests chat-only output and - says they do not want a generated report card. Trigger classes include: - - Direct trigger words: report, document, memo, letter, template - - Creation-intent phrases: "write a document/report/post/article" - - File-intent words: requests containing "save", "file", or "document" when - intent is to create a report-like deliverable - - Word-doc specific triggers: professional report-style deliverable, - professional document, Word doc, .docx - - Other report-like output intents: one-pager, blog post, article, - standalone written content, comprehensive guide - - General artifact-style intents: analysis / writing as substantial deliverables - Common triggers include phrases like: - - "Generate a report about this" - - "Write a report from this conversation" - - "Create a detailed report about..." - - "Make a research report on..." - - "Summarize this into a report" - - "Turn this into a report" - - "Write a report/document" - - "Draft a report" - - "Create an executive summary" - - "Make a briefing note" - - "Write a one-pager" - - "Write a blog post" - - "Write an article" - - "Create a comprehensive guide" - - "Prepare a report" - - "Create a small report" - - "Write a short report" - - "Make a quick report" - - "Brief report for class" + - Direct trigger words WITH creation/modification verb: report, + document, memo, letter, template, article, guide, blog post, + one-pager, briefing, comprehensive guide. + - Creation-intent phrases: "write a report", "generate a document", + "draft a summary", "create an executive summary". + - Modification-intent phrases: "revise the report", "update the + report", "make it shorter", "add a section about X", "expand the + budget section", "rewrite in formal tone". + + IMPORTANT — what does NOT count as "asking for a report": + - Questions or discussion about a report or its topic are NOT report + requests. Respond to these conversationally in chat. + Examples: "What other examples to put there?", "What else could be + added?", "Can you explain section 2?", "Is the data accurate?", + "What's missing?", "How could this be improved?", "What other + topics are related?" + - Quick summary requests, explanations, or follow-up questions. + - The test: Does the message contain a creation/modification VERB + (write, create, generate, draft, add, revise, update, expand, + rewrite, make) directed at producing a deliverable? If no verb + → answer in chat. FORMAT/EXPORT RULE: - Always generate the report content in Markdown. - - If the user requests DOCX/Word/PDF or another file format, export from - the generated Markdown report. - SOURCE-COLLECTION RULE: - - If enough source material is already present in the conversation (chat - history, pasted text, uploaded files, or a provided video/article summary), - generate directly from that source_content. - - Use knowledge-base search first only when extra context is needed beyond - what the user already provided. + - If the user requests DOCX/Word/PDF or another file format, export + from the generated Markdown report. + + SOURCE STRATEGY (how to collect source material): + - source_strategy="conversation" — The conversation already has + enough context (prior Q&A, pasted text, uploaded files, scraped + webpages). Pass a thorough summary as source_content. + NEVER call search_knowledge_base separately first. + - source_strategy="kb_search" — Search the knowledge base + internally. Provide 1-5 targeted search_queries. The tool + handles searching — do NOT call search_knowledge_base first. + - source_strategy="provided" — Use only what is in source_content + (default, backward-compatible). + - source_strategy="auto" — Use source_content if it has enough + material; otherwise fall back to internal KB search using + search_queries. + + CONVERSATION REUSE (HIGH PRIORITY): + - If the user has been asking questions in this chat and the + conversation contains substantive answers/discussion on the + topic, prefer source_strategy="conversation" with a thorough + summary of the full chat history as source_content. + - The user's prior questions and your answers ARE the source + material. Do NOT redundantly search the knowledge base for + information that is already in the chat. VERSIONING — parent_report_id: - - Set parent_report_id when the user wants to MODIFY, REVISE, IMPROVE, - UPDATE, EXPAND, or ADD CONTENT TO an existing report that was already - generated in this conversation. - - This includes both explicit AND implicit modification requests. If the - user references the existing report using words like "it", "this", - "here", "the report", or clearly refers to a previously generated - report, treat it as a revision request. + - Set parent_report_id when the user wants to MODIFY, REVISE, + IMPROVE, UPDATE, EXPAND, or ADD CONTENT TO an existing report + that was already generated in this conversation. + - This includes both explicit AND implicit modification requests. + If the user references the existing report using words like "it", + "this", "here", "the report", or clearly refers to a previously + generated report, treat it as a revision request. - The value must be the report_id from a previous generate_report result in this same conversation. - Do NOT set parent_report_id when: * The user asks for a report on a completely NEW/DIFFERENT topic - * The user says "generate another report" (new report, not a revision) + * The user says "generate another report" (new report, not revision) * There is no prior report to reference - - When parent_report_id is set, the previous report's content will be - used as a base. Your user_instructions should describe WHAT TO CHANGE. Examples of when to SET parent_report_id: User: "Make that report shorter" → parent_report_id = @@ -207,30 +686,28 @@ def create_generate_report_tool( User: "Also mention the competitor landscape" → parent_report_id = Examples of when to LEAVE parent_report_id as None: - User: "Generate a report on climate change" → parent_report_id = None (new topic) - User: "Write me a report about the budget" → parent_report_id = None (new topic) - User: "Create another report, this time about marketing" → parent_report_id = None - User: "Now write one about travel trends in Europe" → parent_report_id = None (new topic despite "now") - User: "Do the same kind of report but for the fitness industry" → parent_report_id = None (new topic, different subject) + User: "Generate a report on climate change" → None (new topic) + User: "Write me a report about the budget" → None (new topic) + User: "Create another report, this time about marketing" → None + User: "Now write one about travel trends in Europe" → None (new topic) Args: - 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 - * You can combine both: conversation context + search results for richer reports - * The more detailed the source_content, the better the report quality - report_style: Style of the report. Options: "detailed", "executive_summary", "deep_research", "brief". Default: "detailed" - user_instructions: Optional specific instructions for the report (e.g., "focus on financial impacts", "include recommendations"). When revising an existing report (parent_report_id is set), this should describe the changes to make. - parent_report_id: Optional ID of a previously generated report to revise. When set, the new report is created as a new version in the same version group. The previous report's content is included as context for the LLM to refine. + topic: Short title for the report (max ~8 words). + source_content: Text to base the report on. Can be empty when + using source_strategy="kb_search". + source_strategy: How to collect source material. One of + "provided", "conversation", "kb_search", or "auto". + search_queries: When source_strategy is "kb_search" or "auto", + provide 1-5 targeted search queries for the knowledge base. + These should be specific, not just the topic repeated. + report_style: "detailed", "deep_research", or "brief". + user_instructions: Optional focus or modification instructions. + When revising (parent_report_id set), describe WHAT TO CHANGE. + parent_report_id: ID of a previous report to revise (creates new + version in the same version group). Returns: - A dictionary containing: - - status: "ready" or "failed" - - report_id: The report ID - - title: The report title - - word_count: Number of words in the report - - message: Status message (or "error" field if failed) + Dict with status, report_id, title, word_count, and message. """ # Initialize version tracking variables (used by _save_failed_report closure) parent_report_content: str | None = None @@ -304,33 +781,212 @@ def create_generate_report_tool( "title": topic, } - # Build the prompt + # Build the user instructions string user_instructions_section = "" if user_instructions: user_instructions_section = ( f"**Additional Instructions:** {user_instructions}" ) - # If revising, include previous version content - previous_version_section = "" - if parent_report_content: - previous_version_section = ( - "**Previous Version of This Report (refine this based on the instructions above — " - "preserve structure and quality, apply only the requested changes):**\n\n" - f"{parent_report_content}" + # ── Phase 1b: SOURCE COLLECTION (smart KB search) ──────────── + # Decide whether to augment source_content with KB search results. + effective_source = source_content or "" + + strategy = (source_strategy or "provided").lower().strip() + + needs_kb_search = False + if strategy == "kb_search": + needs_kb_search = True + elif strategy == "auto": + # Heuristic: if source_content has fewer than 200 words, + # it's likely insufficient — augment with KB search. + word_count_estimate = len(effective_source.split()) + if word_count_estimate < 200: + needs_kb_search = True + logger.info( + f"[generate_report] auto strategy: source has ~{word_count_estimate} words, " + "triggering KB search" + ) + # "provided" and "conversation" → use source_content as-is + + if needs_kb_search and connector_service and search_queries: + query_count = min(len(search_queries), 5) + dispatch_custom_event( + "report_progress", + { + "phase": "kb_search", + "message": f"Searching knowledge base ({query_count} queries)...", + }, + ) + logger.info( + f"[generate_report] Running internal KB search with " + f"{query_count} queries: {search_queries[:5]}" + ) + try: + from .knowledge_base import search_knowledge_base_async + + # Run all queries in parallel, each with its own session + async def _run_single_query(q: str) -> str: + async with async_session_maker() as kb_session: + kb_connector_svc = ConnectorService( + kb_session, search_space_id + ) + return await search_knowledge_base_async( + query=q, + search_space_id=search_space_id, + db_session=kb_session, + connector_service=kb_connector_svc, + top_k=10, + available_connectors=available_connectors, + ) + + kb_results = await asyncio.gather( + *[_run_single_query(q) for q in search_queries[:5]] + ) + + # Merge non-empty results into source_content + kb_text_parts = [r for r in kb_results if r and r.strip()] + if kb_text_parts: + kb_combined = "\n\n---\n\n".join(kb_text_parts) + if effective_source.strip(): + effective_source = ( + effective_source + + "\n\n--- Knowledge Base Search Results ---\n\n" + + kb_combined + ) + else: + effective_source = kb_combined + + # Count docs found (rough: count tags) + doc_count = kb_combined.count("") + dispatch_custom_event( + "report_progress", + { + "phase": "kb_search_done", + "message": f"Found {doc_count} relevant documents" + if doc_count + else f"Found results from {len(kb_text_parts)} queries", + }, + ) + logger.info( + f"[generate_report] KB search added ~{len(kb_combined)} chars " + f"from {len(kb_text_parts)} queries" + ) + else: + dispatch_custom_event( + "report_progress", + { + "phase": "kb_search_done", + "message": "No results found in knowledge base", + }, + ) + logger.info("[generate_report] KB search returned no results") + + except Exception as e: + logger.warning( + f"[generate_report] Internal KB search failed: {e}. " + "Proceeding with existing source_content." + ) + elif needs_kb_search and not connector_service: + logger.warning( + "[generate_report] KB search requested but connector_service " + "not available. Using source_content as-is." + ) + elif needs_kb_search and not search_queries: + logger.warning( + "[generate_report] KB search requested but no search_queries " + "provided. Using source_content as-is." ) - prompt = _REPORT_PROMPT.format( - topic=topic, - report_style=report_style, - user_instructions_section=user_instructions_section, - previous_version_section=previous_version_section, - source_content=source_content[:100000], # Cap source content - ) + capped_source = effective_source[:100000] # Cap source content - # ── Phase 2: LLM CALL (no DB connection held) ──────────────── - response = await llm.ainvoke([HumanMessage(content=prompt)]) - report_content = response.content + # Length constraint — only when user explicitly asks for brevity + length_instruction = "" + if report_style == "brief": + length_instruction = ( + "**LENGTH CONSTRAINT (MANDATORY):** The user wants a SHORT report. " + "Keep it concise — aim for ~400 words (~1 page) unless a different " + "length is specified in the Additional Instructions above. " + "Prioritize brevity over thoroughness. Do NOT write a long report." + ) + + # ── Phase 2: LLM GENERATION (no DB connection held) ────────── + + report_content: str | None = None + + if parent_report_content: + # ─── REVISION MODE ─────────────────────────────────────── + # Strategy: Try section-level revision first (preserves + # unchanged sections byte-for-byte). Falls back to full- + # document revision if section identification fails or if + # all sections need changes. + dispatch_custom_event( + "report_progress", + { + "phase": "revision_start", + "message": "Analyzing sections to modify...", + }, + ) + logger.info( + "[generate_report] Revision mode — attempting section-level revision" + ) + report_content = await _revise_with_sections( + llm=llm, + parent_content=parent_report_content, + user_instructions=user_instructions + or "Improve and refine the report.", + source_content=capped_source, + topic=topic, + report_style=report_style, + ) + + if report_content is None: + # Fallback: full-document revision + dispatch_custom_event( + "report_progress", + {"phase": "writing", "message": "Rewriting your full report"}, + ) + logger.info( + "[generate_report] Section-level revision deferred, " + "using full-document revision" + ) + prompt = _REVISION_PROMPT.format( + topic=topic, + report_style=report_style, + user_instructions_section=user_instructions_section + or "Improve and refine the report.", + source_content=capped_source, + previous_report_content=parent_report_content, + length_instruction=length_instruction, + formatting_rules=_FORMATTING_RULES, + ) + response = await llm.ainvoke([HumanMessage(content=prompt)]) + report_content = response.content + + else: + # ─── NEW REPORT MODE ───────────────────────────────────── + # Single-shot generation: one LLM call produces the full + # report. Fast, globally coherent, and cost-efficient. + dispatch_custom_event( + "report_progress", + {"phase": "writing", "message": "Writing your report"}, + ) + logger.info( + "[generate_report] New report — using single-shot generation" + ) + prompt = _REPORT_PROMPT.format( + topic=topic, + report_style=report_style, + user_instructions_section=user_instructions_section, + previous_version_section="", + source_content=capped_source, + length_instruction=length_instruction, + formatting_rules=_FORMATTING_RULES, + ) + response = await llm.ainvoke([HumanMessage(content=prompt)]) + report_content = response.content + + # ── Validate LLM output ────────────────────────────────────── if not report_content or not isinstance(report_content, str): error_msg = "LLM returned empty or invalid content" @@ -343,7 +999,6 @@ def create_generate_report_tool( } # LLMs often wrap output in ```markdown ... ``` fences — strip them - # so the stored content is clean Markdown. report_content = _strip_wrapping_code_fences(report_content) if not report_content: @@ -356,6 +1011,16 @@ def create_generate_report_tool( "title": topic, } + # Strip any existing footer(s) carried over from parent version(s) + while report_content.rstrip().endswith(_REPORT_FOOTER): + idx = report_content.rstrip().rfind(_REPORT_FOOTER) + report_content = report_content[:idx].rstrip() + if report_content.rstrip().endswith("---"): + report_content = report_content.rstrip()[:-3].rstrip() + + # Append exactly one standard disclaimer + report_content += "\n\n---\n\n" + _REPORT_FOOTER + # Extract metadata (includes "status": "ready") metadata = _extract_metadata(report_content) @@ -396,6 +1061,7 @@ def create_generate_report_tool( "report_id": saved_report_id, "title": topic, "word_count": metadata.get("word_count", 0), + "is_revision": bool(parent_report_content), "message": f"Report generated successfully: {topic}", } diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 0decbb2a4..c74ebfe71 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -28,7 +28,7 @@ from app.agents.new_chat.llm_config import ( load_agent_config, load_llm_config_from_yaml, ) -from app.db import ChatVisibility, Document, SurfsenseDocsDocument +from app.db import ChatVisibility, Document, Report, SurfsenseDocsDocument from app.prompts import TITLE_GENERATION_PROMPT_TEMPLATE from app.services.chat_session_state_service import ( clear_ai_responding, @@ -226,6 +226,7 @@ async def _stream_agent_events( last_active_step_title: str = initial_step_title last_active_step_items: list[str] = initial_step_items or [] just_finished_tool: bool = False + active_tool_depth: int = 0 # Track nesting: >0 means we're inside a tool def next_thinking_step_id() -> str: nonlocal thinking_step_counter @@ -250,6 +251,8 @@ async def _stream_agent_events( event_type = event.get("event", "") if event_type == "on_chat_model_stream": + if active_tool_depth > 0: + continue # Suppress inner-tool LLM tokens from leaking into chat chunk = event.get("data", {}).get("chunk") if chunk and hasattr(chunk, "content"): content = chunk.content @@ -269,6 +272,7 @@ async def _stream_agent_events( accumulated_text += content elif event_type == "on_tool_start": + active_tool_depth += 1 tool_name = event.get("name", "unknown_tool") run_id = event.get("run_id", "") tool_input = event.get("data", {}).get("input", {}) @@ -383,26 +387,18 @@ async def _stream_agent_events( if isinstance(tool_input, dict) else "Report" ) - report_style = ( - tool_input.get("report_style", "detailed") - if isinstance(tool_input, dict) - else "detailed" + is_revision = bool( + isinstance(tool_input, dict) and tool_input.get("parent_report_id") ) - content_len = len( - tool_input.get("source_content", "") - if isinstance(tool_input, dict) - else "" - ) - last_active_step_title = "Generating report" + step_title = "Revising report" if is_revision else "Generating report" + last_active_step_title = step_title last_active_step_items = [ f"Topic: {report_topic}", - f"Style: {report_style}", - f"Source content: {content_len:,} characters", - "Generating report with LLM...", + "Analyzing source content...", ] yield streaming_service.format_thinking_step( step_id=tool_step_id, - title="Generating report", + title=step_title, status="in_progress", items=last_active_step_items, ) @@ -428,6 +424,7 @@ async def _stream_agent_events( ) elif event_type == "on_tool_end": + active_tool_depth = max(0, active_tool_depth - 1) run_id = event.get("run_id", "") tool_name = event.get("name", "unknown_tool") raw_output = event.get("data", {}).get("output", "") @@ -589,12 +586,18 @@ async def _stream_agent_events( if isinstance(tool_output, dict) else 0 ) + is_revision = ( + tool_output.get("is_revision", False) + if isinstance(tool_output, dict) + else False + ) + step_title = "Revising report" if is_revision else "Generating report" if report_status == "ready": completed_items = [ - f"Title: {report_title}", - f"Words: {word_count:,}", - "Report generated successfully", + f"Topic: {report_title}", + f"{word_count:,} words", + "Report ready", ] elif report_status == "failed": error_msg = ( @@ -603,7 +606,7 @@ async def _stream_agent_events( else "Unknown error" ) completed_items = [ - f"Title: {report_title}", + f"Topic: {report_title}", f"Error: {error_msg[:50]}", ] else: @@ -611,7 +614,7 @@ async def _stream_agent_events( yield streaming_service.format_thinking_step( step_id=original_step_id, - title="Generating report", + title=step_title, status="completed", items=completed_items, ) @@ -815,6 +818,43 @@ async def _stream_agent_events( f"Tool {tool_name} completed", "success" ) + elif event_type == "on_custom_event" and event.get("name") == "report_progress": + # Live progress updates from inside the generate_report tool + data = event.get("data", {}) + message = data.get("message", "") + if message and last_active_step_id: + phase = data.get("phase", "") + # Always keep the "Topic: ..." line + topic_items = [ + item for item in last_active_step_items if item.startswith("Topic:") + ] + + if phase in ("revising_section", "adding_section"): + # During section-level ops: keep plan summary + show current op + plan_items = [ + item + for item in last_active_step_items + if item.startswith("Topic:") + or item.startswith("Modifying ") + or item.startswith("Adding ") + or item.startswith("Removing ") + ] + # Only keep plan_items that don't end with "..." (not progress lines) + plan_items = [ + item for item in plan_items if not item.endswith("...") + ] + last_active_step_items = [*plan_items, message] + else: + # Phase transitions: replace everything after topic + last_active_step_items = [*topic_items, message] + + yield streaming_service.format_thinking_step( + step_id=last_active_step_id, + title=last_active_step_title, + status="in_progress", + items=last_active_step_items, + ) + elif event_type in ("on_chain_end", "on_agent_end"): if current_text_id is not None: yield streaming_service.format_text_end(current_text_id) @@ -996,6 +1036,20 @@ async def stream_new_chat( ) mentioned_surfsense_docs = list(result.scalars().all()) + # Fetch the most recent report(s) in this thread so the LLM can + # easily find report_id for versioning decisions, instead of + # having to dig through conversation history. + recent_reports_result = await session.execute( + select(Report) + .filter( + Report.thread_id == chat_id, + Report.content.isnot(None), # exclude failed reports + ) + .order_by(Report.id.desc()) + .limit(3) + ) + recent_reports = list(recent_reports_result.scalars().all()) + # Format the user query with context (mentioned documents + SurfSense docs) final_query = user_query context_parts = [] @@ -1010,6 +1064,27 @@ async def stream_new_chat( format_mentioned_surfsense_docs_as_context(mentioned_surfsense_docs) ) + # Surface report IDs prominently so the LLM doesn't have to + # retrieve them from old tool responses in conversation history. + if recent_reports: + report_lines = [] + for r in recent_reports: + report_lines.append( + f' - report_id={r.id}, title="{r.title}", ' + f'style="{r.report_style or "detailed"}"' + ) + reports_listing = "\n".join(report_lines) + context_parts.append( + "\n" + "Previously generated reports in this conversation:\n" + f"{reports_listing}\n\n" + "If the user wants to MODIFY, REVISE, UPDATE, or ADD to one of " + "these reports, set parent_report_id to the relevant report_id above.\n" + "If the user wants a completely NEW report on a different topic, " + "leave parent_report_id unset.\n" + "" + ) + if context_parts: context = "\n\n".join(context_parts) final_query = f"{context}\n\n{user_query}" diff --git a/surfsense_web/components/markdown-viewer.tsx b/surfsense_web/components/markdown-viewer.tsx index 297c850e0..7e0fc17b1 100644 --- a/surfsense_web/components/markdown-viewer.tsx +++ b/surfsense_web/components/markdown-viewer.tsx @@ -24,8 +24,9 @@ interface MarkdownViewerProps { */ function stripOuterMarkdownFence(content: string): string { const trimmed = content.trim(); - const match = trimmed.match(/^```(?:markdown|md)?\s*\n([\s\S]+?)\n```\s*$/); - return match ? match[1] : content; + // Match 3+ backtick fences (LLMs escalate to 4+ when content has triple-backtick blocks) + const match = trimmed.match(/^(`{3,})(?:markdown|md)?\s*\n([\s\S]+?)\n\1\s*$/); + return match ? match[2] : content; } /** diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index ece5f7fae..f974fa499 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; -import { Globe, User, Users } from "lucide-react"; +import { Earth, User, Users } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -244,7 +244,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS )} >
- +