Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions src/memos/api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ def get_activation_config() -> dict[str, Any]:

@staticmethod
def get_memreader_config() -> dict[str, Any]:
"""Get MemReader configuration for chat/doc extraction (fine-tuned 0.6B model).
"""Get the main MemReader configuration for text/chat extraction.

When MEMREADER_GENERAL_MODEL is configured (i.e. a separate stable LLM exists),
the backup client is automatically enabled so that primary failures (self-deployed
Expand Down Expand Up @@ -444,7 +444,7 @@ def get_qwen_llm_config() -> dict[str, Any] | None:

@staticmethod
def get_memreader_general_llm_config() -> dict[str, Any]:
"""Get general LLM configuration for non-chat/doc tasks.
"""Get general LLM configuration for non-primary extraction tasks.

Used for: hallucination filter, memory rewrite, memory merge,
tool trajectory extraction, skill memory extraction.
Expand Down Expand Up @@ -478,6 +478,24 @@ def get_image_parser_llm_config() -> dict[str, Any]:
# Fallback to general_llm config (which itself falls back to OpenAI)
return APIConfig.get_memreader_general_llm_config()

@staticmethod
def get_document_parser_llm_config() -> dict[str, Any] | None:
"""Get the dedicated LLM configuration for document extraction.

The provider endpoint and credentials are selected from QWEN_* or
OPENAI_* according to DOCUMENT_PARSER_MODEL.

Fallback chain: DOCUMENT_PARSER_MODEL -> MEMREADER_GENERAL_MODEL.
Returns None when neither model is configured.
"""
document_model = os.getenv("DOCUMENT_PARSER_MODEL") or os.getenv("MEMREADER_GENERAL_MODEL")
if not document_model:
return None
return APIConfig._build_provider_llm_config(
document_model,
temperature=0.8,
)

@staticmethod
def get_preference_extractor_llm_config() -> dict[str, Any]:
"""Get LLM configuration for preference extraction.
Expand Down Expand Up @@ -994,6 +1012,8 @@ def get_product_default_config() -> dict[str, Any]:
"general_llm": APIConfig.get_memreader_general_llm_config(),
# Image parser LLM (requires vision model)
"image_parser_llm": APIConfig.get_image_parser_llm_config(),
# Dedicated LLM for document chunk extraction
"document_parser_llm": APIConfig.get_document_parser_llm_config(),
# Preference extractor LLM. Reader falls back to general_llm when unset.
"preference_extractor_llm": APIConfig.get_preference_extractor_llm_config()
if os.getenv("PREFERENCE_EXTRACTOR_MODEL")
Expand Down Expand Up @@ -1127,6 +1147,8 @@ def create_user_config(user_name: str, user_id: str) -> tuple["MOSConfig", "Gene
"general_llm": APIConfig.get_memreader_general_llm_config(),
# Image parser LLM (requires vision model)
"image_parser_llm": APIConfig.get_image_parser_llm_config(),
# Dedicated LLM for document chunk extraction
"document_parser_llm": APIConfig.get_document_parser_llm_config(),
# Preference extractor LLM. Reader falls back to general_llm when unset.
"preference_extractor_llm": APIConfig.get_preference_extractor_llm_config()
if os.getenv("PREFERENCE_EXTRACTOR_MODEL")
Expand Down
7 changes: 6 additions & 1 deletion src/memos/configs/mem_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ def parse_datetime(cls, value):
return value

llm: LLMConfigFactory = Field(
..., description="LLM configuration for chat/doc memory extraction (fine-tuned model)"
...,
description="Main LLM configuration for standard text/chat memory extraction",
)
general_llm: LLMConfigFactory | None = Field(
default=None,
Expand All @@ -36,6 +37,10 @@ def parse_datetime(cls, value):
default=None,
description="Vision LLM for image parsing. Falls back to general_llm if not set.",
)
document_parser_llm: LLMConfigFactory | None = Field(
default=None,
description="Dedicated LLM for document content extraction",
)
preference_extractor_llm: LLMConfigFactory | None = Field(
default=None,
description="LLM for preference extraction. Falls back to general_llm if not set.",
Expand Down
9 changes: 8 additions & 1 deletion src/memos/mem_reader/multi_modal_struct.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,19 @@ def __init__(self, config: MultiModalStructMemReaderConfig):
if config.image_parser_llm is not None
else self.general_llm
)
# Document parser LLM (separate from the fine-tuned chat extractor)
self.document_parser_llm = (
LLMFactory.from_config(config.document_parser_llm)
if config.document_parser_llm is not None
else None
)
# Initialize MultiModalParser for routing to different parsers
# Pass image_parser_llm for image parsing
# Pass dedicated image/document LLMs to their corresponding parsers
self.multi_modal_parser = MultiModalParser(
embedder=self.embedder,
llm=self.llm,
image_parser_llm=self.image_parser_llm,
document_parser_llm=self.document_parser_llm,
parser=None,
direct_markdown_hostnames=direct_markdown_hostnames,
)
Expand Down
50 changes: 47 additions & 3 deletions src/memos/mem_reader/read_multi_modal/file_content_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def _get_doc_llm_response(
def _handle_url(self, url_str: str, filename: str) -> tuple[str, str | None, bool]:
"""Download and parse file from URL."""
try:
from urllib.parse import urlparse
from urllib.parse import unquote, urlparse

import requests

Expand All @@ -118,7 +118,13 @@ def _handle_url(self, url_str: str, filename: str) -> tuple[str, str | None, boo
return response.text, None, True

file_ext = os.path.splitext(filename)[1].lower()
if file_ext in [".md", ".markdown", ".txt"] or self._is_oss_md(url_str):
url_path_ext = os.path.splitext(unquote(parsed_url.path))[1].lower()
markdown_extensions = {".md", ".markdown", ".txt"}
if (
file_ext in markdown_extensions
or url_path_ext in markdown_extensions
or self._is_oss_md(url_str)
):
return response.text, None, True
with tempfile.NamedTemporaryFile(mode="wb", delete=False, suffix=file_ext) as temp_file:
temp_file.write(response.content)
Expand Down Expand Up @@ -333,7 +339,7 @@ def __init__(

Args:
embedder: Embedder for generating embeddings
llm: Optional LLM for fine mode processing
llm: Optional dedicated LLM for document extraction
parser: Optional parser for parsing file contents
direct_markdown_hostnames: List of hostnames that should return markdown directly
without parsing. If None, reads from FILE_PARSER_DIRECT_MARKDOWN_HOSTNAMES
Expand Down Expand Up @@ -739,6 +745,13 @@ def parse_fine(
logger.info(
f"[Chunker: FileContentParser] Extracted {len(headers)} headers from markdown"
)
document_context = self._build_markdown_document_context(headers, filename)
if document_context:
context_parts = []
if message_text_context:
context_parts.append(f"Related message context:\n{message_text_context}")
context_parts.append(document_context)
message_text_context = "\n\n".join(context_parts)

# Extract and process images from parsed_text
if is_markdown and parsed_text and self.image_parser:
Expand Down Expand Up @@ -1050,6 +1063,37 @@ def _extract_markdown_headers(self, text: str) -> dict[int, dict]:
logger.info(f"[Chunker: FileContentParser] Extracted {len(headers)} headers from markdown")
return headers

@staticmethod
def _build_markdown_document_context(
headers: dict[int, dict],
filename: str,
) -> str | None:
"""Build document-level context from a Markdown filename and H1 titles."""
h1_titles = []
seen_titles = set()
for header in headers.values():
if header.get("level") != 1:
continue
title = str(header.get("title", "")).strip()
if not title or title in seen_titles:
continue
seen_titles.add(title)
h1_titles.append(title)

if not filename and not h1_titles:
return None

lines = [
"Document-level context for disambiguation only. "
"Do not create memories from this context unless the current chunk supports them."
]
if filename:
lines.append(f"Filename: {filename}")
if h1_titles:
lines.append("Top-level Markdown headings:")
lines.extend(f"- {title}" for title in h1_titles)
return "\n".join(lines)

def _get_header_context(
self, text: str, image_position: int, headers: dict[int, dict]
) -> list[str]:
Expand Down
7 changes: 5 additions & 2 deletions src/memos/mem_reader/read_multi_modal/multi_modal_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def __init__(
embedder: BaseEmbedder,
llm: BaseLLM | None = None,
image_parser_llm: BaseLLM | None = None,
document_parser_llm: BaseLLM | None = None,
parser: Any | None = None,
direct_markdown_hostnames: list[str] | None = None,
):
Expand All @@ -46,9 +47,10 @@ def __init__(

Args:
embedder: Embedder for generating embeddings
llm: Optional LLM for fine mode processing (chat/doc extraction)
llm: Optional main LLM for standard text/chat extraction.
image_parser_llm: Optional vision LLM for image parsing.
Falls back to llm if not provided.
document_parser_llm: Optional dedicated LLM for document extraction.
parser: Optional parser for parsing file contents
direct_markdown_hostnames: List of hostnames that should return markdown directly
without parsing. If None, reads from FILE_PARSER_DIRECT_MARKDOWN_HOSTNAMES
Expand All @@ -58,6 +60,7 @@ def __init__(
self.llm = llm
# Image parser LLM (requires vision model), falls back to main llm
self.image_parser_llm = image_parser_llm if image_parser_llm is not None else llm
self.document_parser_llm = document_parser_llm
self.parser = parser

# Initialize parsers for different message types
Expand All @@ -71,7 +74,7 @@ def __init__(
self.image_parser = ImageParser(embedder, self.image_parser_llm)
self.file_content_parser = FileContentParser(
embedder,
llm,
self.document_parser_llm,
parser,
direct_markdown_hostnames=direct_markdown_hostnames,
image_parser=self.image_parser,
Expand Down
Loading
Loading