feat: add citation support in research report block and markdown
* feat: add citation support in research report block and markdown - Enhanced ResearchReportBlock to fetch citations based on researchId and pass them to the Markdown component. - Introduced CitationLink component to display citation metadata on hover for links in markdown. - Implemented CitationCard and CitationList components for displaying citation details and lists. - Updated Markdown component to handle citation links and inline citations. - Created HoverCard component for displaying citation information in a tooltip-like manner. - Modified store to manage citations, including setting and retrieving citations for ongoing research. - Added CitationsEvent type to handle citations in chat events and updated Message type to include citations. * fix(log): Enable the logging level when enabling the DEBUG environment variable (#793) * fix(frontend): render all tool calls in the frontend #796 (#797) * build(deps): bump jspdf from 3.0.4 to 4.0.0 in /web (#798) Bumps [jspdf](https://github.com/parallax/jsPDF) from 3.0.4 to 4.0.0. - [Release notes](https://github.com/parallax/jsPDF/releases) - [Changelog](https://github.com/parallax/jsPDF/blob/master/RELEASE.md) - [Commits](https://github.com/parallax/jsPDF/compare/v3.0.4...v4.0.0) --- updated-dependencies: - dependency-name: jspdf dependency-version: 4.0.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(frontend):added the display of the 'analyst' message #800 (#801) * fix: migrate from deprecated create_react_agent to langchain.agents.create_agent (#802) * fix: migrate from deprecated create_react_agent to langchain.agents.create_agent Fixes #799 - Replace deprecated langgraph.prebuilt.create_react_agent with langchain.agents.create_agent (LangGraph 1.0 migration) - Add DynamicPromptMiddleware to handle dynamic prompt templates (replaces the 'prompt' callable parameter) - Add PreModelHookMiddleware to handle pre-model hooks (replaces the 'pre_model_hook' parameter) - Update AgentState import from langchain.agents in template.py - Update tests to use the new API * fix:update the code with review comments * fix: Add runtime parameter to compress_messages method(#803) * fix: Add runtime parameter to compress_messages method(#803) The compress_messages method was being called by PreModelHookMiddleware with both state and runtime parameters, but only accepted state parameter. This caused a TypeError when the middleware executed the pre_model_hook. Added optional runtime parameter to compress_messages signature to match the expected interface while maintaining backward compatibility. * Update the code with the review comments * fix: Refactor citation handling and add comprehensive tests for citation features * refactor: Clean up imports and formatting across citation modules * fix: Add monkeypatch to clear AGENT_RECURSION_LIMIT in recursion limit tests * feat: Enhance citation link handling in Markdown component * fix: Exclude citations from finish reason handling in mergeMessage function * fix(nodes): update message handling * fix(citations): improve citation extraction and handling in event processing * feat(citations): enhance citation extraction and handling with improved merging and normalization * fix(reporter): update citation formatting instructions for clarity and consistency * fix(reporter): prioritize using Markdown tables for data presentation and comparison --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: LoftyComet <1277173875@qq。> Co-authored-by: Willem Jiang <willem.jiang@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
# Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""
|
||||
Citation formatter for generating citation sections and inline references.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from .models import Citation, CitationMetadata
|
||||
|
||||
|
||||
class CitationFormatter:
|
||||
"""
|
||||
Formats citations for display in reports.
|
||||
|
||||
Supports multiple citation styles:
|
||||
- numbered: [1], [2], etc.
|
||||
- superscript: ¹, ², etc.
|
||||
- footnote: [^1], [^2], etc.
|
||||
- inline: (Author, Year) or (Source)
|
||||
"""
|
||||
|
||||
SUPERSCRIPT_MAP = {
|
||||
"0": "⁰",
|
||||
"1": "¹",
|
||||
"2": "²",
|
||||
"3": "³",
|
||||
"4": "⁴",
|
||||
"5": "⁵",
|
||||
"6": "⁶",
|
||||
"7": "⁷",
|
||||
"8": "⁸",
|
||||
"9": "⁹",
|
||||
}
|
||||
|
||||
def __init__(self, style: str = "numbered"):
|
||||
"""
|
||||
Initialize the formatter.
|
||||
|
||||
Args:
|
||||
style: Citation style ('numbered', 'superscript', 'footnote', 'inline')
|
||||
"""
|
||||
self.style = style
|
||||
|
||||
def format_inline_marker(self, number: int) -> str:
|
||||
"""
|
||||
Format an inline citation marker.
|
||||
|
||||
Args:
|
||||
number: The citation number
|
||||
|
||||
Returns:
|
||||
Formatted marker string
|
||||
"""
|
||||
if self.style == "superscript":
|
||||
return "".join(self.SUPERSCRIPT_MAP.get(c, c) for c in str(number))
|
||||
elif self.style == "footnote":
|
||||
return f"[^{number}]"
|
||||
else: # numbered
|
||||
return f"[{number}]"
|
||||
|
||||
def format_reference(self, citation: Citation) -> str:
|
||||
"""
|
||||
Format a single reference for the citations section.
|
||||
|
||||
Args:
|
||||
citation: The citation to format
|
||||
|
||||
Returns:
|
||||
Formatted reference string
|
||||
"""
|
||||
metadata = citation.metadata
|
||||
|
||||
# Build reference with available metadata
|
||||
parts = []
|
||||
|
||||
# Number and title
|
||||
parts.append(f"[{citation.number}] **{metadata.title}**")
|
||||
|
||||
# Author if available
|
||||
if metadata.author:
|
||||
parts.append(f" *{metadata.author}*")
|
||||
|
||||
# Domain/source
|
||||
if metadata.domain:
|
||||
parts.append(f" Source: {metadata.domain}")
|
||||
|
||||
# Published date if available
|
||||
if metadata.published_date:
|
||||
parts.append(f" Published: {metadata.published_date}")
|
||||
|
||||
# URL
|
||||
parts.append(f" URL: {metadata.url}")
|
||||
|
||||
# Description/snippet
|
||||
if metadata.description:
|
||||
snippet = metadata.description[:200]
|
||||
if len(metadata.description) > 200:
|
||||
snippet += "..."
|
||||
parts.append(f" > {snippet}")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def format_simple_reference(self, citation: Citation) -> str:
|
||||
"""
|
||||
Format a simple reference (title + URL).
|
||||
|
||||
Args:
|
||||
citation: The citation to format
|
||||
|
||||
Returns:
|
||||
Simple reference string
|
||||
"""
|
||||
return f"- [{citation.metadata.title}]({citation.metadata.url})"
|
||||
|
||||
def format_rich_reference(self, citation: Citation) -> str:
|
||||
"""
|
||||
Format a rich reference with metadata as JSON-like annotation.
|
||||
|
||||
Args:
|
||||
citation: The citation to format
|
||||
|
||||
Returns:
|
||||
Rich reference string with metadata
|
||||
"""
|
||||
metadata = citation.metadata
|
||||
parts = [f"- [{metadata.title}]({metadata.url})"]
|
||||
|
||||
annotations = []
|
||||
if metadata.domain:
|
||||
annotations.append(f"domain: {metadata.domain}")
|
||||
if metadata.relevance_score > 0:
|
||||
annotations.append(f"relevance: {metadata.relevance_score:.2f}")
|
||||
if metadata.accessed_at:
|
||||
annotations.append(f"accessed: {metadata.accessed_at[:10]}")
|
||||
|
||||
if annotations:
|
||||
parts.append(f" <!-- {', '.join(annotations)} -->")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def format_citations_section(
|
||||
self, citations: List[Citation], include_metadata: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
Format the full citations section for a report.
|
||||
|
||||
Args:
|
||||
citations: List of citations to include
|
||||
include_metadata: Whether to include rich metadata
|
||||
|
||||
Returns:
|
||||
Formatted citations section markdown
|
||||
"""
|
||||
if not citations:
|
||||
return ""
|
||||
|
||||
lines = ["## Key Citations", ""]
|
||||
|
||||
for citation in citations:
|
||||
if include_metadata:
|
||||
lines.append(self.format_rich_reference(citation))
|
||||
else:
|
||||
lines.append(self.format_simple_reference(citation))
|
||||
lines.append("") # Empty line between citations
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_footnotes_section(self, citations: List[Citation]) -> str:
|
||||
"""
|
||||
Format citations as footnotes (for footnote style).
|
||||
|
||||
Args:
|
||||
citations: List of citations
|
||||
|
||||
Returns:
|
||||
Footnotes section markdown
|
||||
"""
|
||||
if not citations:
|
||||
return ""
|
||||
|
||||
lines = ["", "---", ""]
|
||||
for citation in citations:
|
||||
lines.append(
|
||||
f"[^{citation.number}]: {citation.metadata.title} - {citation.metadata.url}"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def add_citation_markers_to_text(
|
||||
self, text: str, citations: List[Citation], url_to_number: Dict[str, int]
|
||||
) -> str:
|
||||
"""
|
||||
Add citation markers to text where URLs are referenced.
|
||||
|
||||
Args:
|
||||
text: The text to process
|
||||
citations: Available citations
|
||||
url_to_number: Mapping from URL to citation number
|
||||
|
||||
Returns:
|
||||
Text with citation markers added
|
||||
"""
|
||||
|
||||
# Find all markdown links and add citation numbers
|
||||
def replace_link(match):
|
||||
full_match = match.group(0)
|
||||
url = match.group(2)
|
||||
|
||||
if url in url_to_number:
|
||||
number = url_to_number[url]
|
||||
marker = self.format_inline_marker(number)
|
||||
return f"{full_match}{marker}"
|
||||
return full_match
|
||||
|
||||
pattern = r"\[([^\]]+)\]\(([^)]+)\)"
|
||||
return re.sub(pattern, replace_link, text)
|
||||
|
||||
@staticmethod
|
||||
def build_citation_data_json(citations: List[Citation]) -> str:
|
||||
"""
|
||||
Build a JSON block containing citation data for frontend use.
|
||||
|
||||
Args:
|
||||
citations: List of citations
|
||||
|
||||
Returns:
|
||||
JSON string with citation data
|
||||
"""
|
||||
import json
|
||||
|
||||
data = {
|
||||
"citations": [c.to_dict() for c in citations],
|
||||
"count": len(citations),
|
||||
}
|
||||
|
||||
return json.dumps(data, ensure_ascii=False)
|
||||
|
||||
|
||||
def parse_citations_from_report(report: str) -> List[Tuple[str, str]]:
|
||||
"""
|
||||
Parse citation links from a report's Key Citations section.
|
||||
|
||||
Args:
|
||||
report: The report markdown text
|
||||
|
||||
Returns:
|
||||
List of (title, url) tuples
|
||||
"""
|
||||
citations = []
|
||||
|
||||
# Find the Key Citations section
|
||||
section_pattern = (
|
||||
r"(?:##\s*Key Citations|##\s*References|##\s*Sources)\s*\n(.*?)(?=\n##|\Z)"
|
||||
)
|
||||
section_match = re.search(section_pattern, report, re.IGNORECASE | re.DOTALL)
|
||||
|
||||
if section_match:
|
||||
section = section_match.group(1)
|
||||
|
||||
# Extract markdown links
|
||||
link_pattern = r"\[([^\]]+)\]\(([^)]+)\)"
|
||||
for match in re.finditer(link_pattern, section):
|
||||
title = match.group(1)
|
||||
url = match.group(2)
|
||||
if url.startswith(("http://", "https://")):
|
||||
citations.append((title, url))
|
||||
|
||||
return citations
|
||||
Reference in New Issue
Block a user