diff --git a/api/apps/services/provider_api_service.py b/api/apps/services/provider_api_service.py index 60f9b11f03..33805e1d39 100644 --- a/api/apps/services/provider_api_service.py +++ b/api/apps/services/provider_api_service.py @@ -82,20 +82,10 @@ def list_providers(tenant_id: str, all_available: bool = False): for factory_info in FACTORY_LLM_INFOS: if factory_info["name"] in ["Youdao", "FastEmbed", "BAAI", "Builtin", "siliconflow_intl"]: continue - model_types = sorted(set( - model_type - for llm in factory_info.get("llm", []) - for model_type in _factory_model_types(llm) - )) if factory_info.get("llm", []) else [] + model_types = sorted(set(model_type for llm in factory_info.get("llm", []) for model_type in _factory_model_types(llm))) if factory_info.get("llm", []) else [] if factory_info["name"] in ["MinerU", "PaddleOCR", "OpenDataLoader"]: model_types.append("ocr") - provider = { - "model_types": model_types, - "name": factory_info["name"], - "url": { - "default": factory_info.get("url", "") - } - } + provider = {"model_types": model_types, "name": factory_info["name"], "url": {"default": factory_info.get("url", "")}} if factory_info["name"].lower() == "siliconflow": provider["url"]["intl"] = factory_info_map.get("siliconflow_intl", {}).get("url", "https://api.siliconflow.com/v1") elif factory_info["name"] == "Tongyi-Qianwen": @@ -112,21 +102,11 @@ def list_providers(tenant_id: str, all_available: bool = False): for name in factory_names: if name not in ["Youdao", "FastEmbed", "BAAI", "Builtin", "siliconflow_intl"] and factory_info_mapping.get(name): factory_info = factory_info_mapping[name] - model_types = sorted(set( - model_type - for llm in factory_info.get("llm", []) - for model_type in _factory_model_types(llm) - )) if factory_info.get("llm", []) else [] + model_types = sorted(set(model_type for llm in factory_info.get("llm", []) for model_type in _factory_model_types(llm))) if factory_info.get("llm", []) else [] if name in ["MinerU", "PaddleOCR", "OpenDataLoader"]: model_types.append("ocr") - provider = { - "model_types": model_types, - "name": factory_info["name"], - "url": { - "default": factory_info.get("url", "") - } - } + provider = {"model_types": model_types, "name": factory_info["name"], "url": {"default": factory_info.get("url", "")}} if factory_info["name"].lower() == "siliconflow": provider["url"]["intl"] = factory_info_map.get("siliconflow_intl", {}).get("url", "https://api.siliconflow.com/v1") elif factory_info["name"] == "Tongyi-Qianwen": @@ -155,10 +135,7 @@ def add_provider(tenant_id: str, provider_name: str): if existing: return False, f"Provider {provider_name} already exists" - TenantModelProviderService.insert( - tenant_id=tenant_id, - provider_name=provider_name - ) + TenantModelProviderService.insert(tenant_id=tenant_id, provider_name=provider_name) return True, "success" @@ -195,17 +172,11 @@ def show_provider(provider_id_or_name: str): if provider_id_or_name: _, provider_obj = TenantModelProviderService.get_by_id(provider_id_or_name) provider_name = provider_obj.provider_name if provider_obj else provider_id_or_name - fac_list = [f for f in FACTORY_LLM_INFOS if f["name"]==provider_name] + fac_list = [f for f in FACTORY_LLM_INFOS if f["name"] == provider_name] if not fac_list: return False, f"Provider '{provider_id_or_name}' not found" factory_info = fac_list[0] - return True, { - "base_url": { - "default": factory_info.get("url", "") - }, - "name": factory_info["name"], - "total_models": len(factory_info.get("llm", [])) - } + return True, {"base_url": {"default": factory_info.get("url", "")}, "name": factory_info["name"], "total_models": len(factory_info.get("llm", []))} async def list_provider_models(provider_id_or_name: str, api_key: str = None, base_url: str = None): @@ -225,19 +196,15 @@ async def list_provider_models(provider_id_or_name: str, api_key: str = None, ba if not factory_info: return False, f"Provider '{provider_id_or_name}' not found" api_key = _normalize_provider_api_key(provider_name, api_key) - static_llms = [{ + static_llms = [ + { "name": _factory_llm_name(llm), "max_tokens": llm["max_tokens"], "model_types": _factory_model_types(llm), - "features": ( - llm.get("features") - if llm.get("features") is not None - else ( - (["is_tools"] if llm.get("is_tools") else []) - + (["thinking"] if llm.get("thinking") else []) - ) - ) - } for llm in factory_info[0]["llm"]] + "features": (llm.get("features") if llm.get("features") is not None else ((["is_tools"] if llm.get("is_tools") else []) + (["thinking"] if llm.get("thinking") else []))), + } + for llm in factory_info[0]["llm"] + ] model_base_url = _normalize_provider_base_url(provider_name, base_url) or factory_info[0].get("url", "") remote_models = [] @@ -284,11 +251,11 @@ def show_provider_model(provider_id_or_name: str, model_name: str): "max_tokens": llm_info["max_tokens"], "model_types": _factory_model_types(llm_info), "thinking": None, - "model_type_map": {model_type: True for model_type in _factory_model_types(llm_info)} + "model_type_map": {model_type: True for model_type in _factory_model_types(llm_info)}, } -async def create_provider_instance(tenant_id: str, provider_id_or_name: str, instance_name: str, api_key: str|dict, base_url: str, region: str, model_info: list[dict]=None): +async def create_provider_instance(tenant_id: str, provider_id_or_name: str, instance_name: str, api_key: str | dict, base_url: str, region: str, model_info: list[dict] = None): """ Create a provider instance. @@ -346,12 +313,35 @@ async def create_provider_instance(tenant_id: str, provider_id_or_name: str, ins if not success: return False, msg + # For SoMark, embed OCR config from model_info into the api_key JSON so + # SoMarkOcrModel.__init__ can read it via the existing key → api_key_payload + # path. This avoids changing the deprecated LLMBundle in tenant_llm_service.py. + if provider_name == "SoMark" and model_info: + cfg = {} + if api_key_str: + try: + cfg = json.loads(api_key_str) + except Exception: + pass + if not isinstance(cfg, dict): + cfg = {} + for model in model_info: + if model.get("extra"): + cfg.update(model["extra"]) + if base_url: + cfg["SOMARK_BASE_URL"] = base_url + api_key_str = json.dumps(cfg) + + success, msg = await verify_api_key(provider_name, api_key, base_url, region, model_info) + if not success: + return False, msg + extra_fields = {} if base_url: extra_fields["base_url"] = base_url if region: extra_fields["region"] = region - TenantModelInstanceService.create_instance(provider_id=provider_obj.id,instance_name=instance_name,api_key=api_key_str, extra=json.dumps(extra_fields)) + TenantModelInstanceService.create_instance(provider_id=provider_obj.id, instance_name=instance_name, api_key=api_key_str, extra=json.dumps(extra_fields)) if model_info: msg = "" for model in model_info: @@ -384,18 +374,20 @@ def list_provider_instances(tenant_id: str, provider_id_or_name: str): instances = [] for instance_obj in instance_objs: extra_fields = json.loads(instance_obj.extra) if instance_obj.extra else {} - instances.append({ - "id": instance_obj.id, - "instance_name": instance_obj.instance_name, - "provider_id": provider_id, - "region": extra_fields.get("region", ""), - "status": instance_obj.status, - }) + instances.append( + { + "id": instance_obj.id, + "instance_name": instance_obj.instance_name, + "provider_id": provider_id, + "region": extra_fields.get("region", ""), + "status": instance_obj.status, + } + ) return True, instances -async def verify_api_key(provider_id_or_name: str, api_key: str|dict, base_url: str=None, region: str=None, model_info: list[dict]=None): +async def verify_api_key(provider_id_or_name: str, api_key: str | dict, base_url: str = None, region: str = None, model_info: list[dict] = None): """ Verify API key for a provider. @@ -438,10 +430,15 @@ async def verify_api_key(provider_id_or_name: str, api_key: str|dict, base_url: if not factory_llms: if not model_info: return False, f"No models found for provider '{provider_id_or_name}'" - factory_llms = [{ - "model_type": _type, - "llm_name": model.get("model_name", ""), - } for model in model_info if model for _type in model.get("model_type", []) ] + factory_llms = [ + { + "model_type": _type, + "llm_name": model.get("model_name", ""), + } + for model in model_info + if model + for _type in model.get("model_type", []) + ] if not factory_llms: return False, f"No valid models found for provider '{provider_id_or_name}'" @@ -481,11 +478,12 @@ async def verify_api_key(provider_id_or_name: str, api_key: str|dict, base_url: assert provider_name in ChatModel, f"Chat model from {provider_name} is not supported yet." mdl = ChatModel[provider_name](api_key_str, llm["llm_name"], base_url=base_url, **extra) try: + async def check_streamly(): async for chunk in mdl.async_chat_streamly( - None, - [{"role": "user", "content": "Hi"}], - {"temperature": 0.9}, + None, + [{"role": "user", "content": "Hi"}], + {"temperature": 0.9}, ): if chunk and isinstance(chunk, str) and chunk.find("**ERROR**") < 0: return True @@ -548,6 +546,7 @@ async def verify_api_key(provider_id_or_name: str, api_key: str|dict, base_url: assert provider_name in TTSModel, f"TTS model from {provider_name} is not supported yet." mdl = TTSModel[provider_name](key=api_key_str, model_name=llm["llm_name"], base_url=base_url) try: + def drain_tts(): for _ in mdl.tts("Hello~ RAGFlower!"): pass @@ -598,13 +597,7 @@ def show_provider_instance(tenant_id: str, provider_id_or_name: str, instance_id return False, f"No instance found for provider '{provider_id_or_name}' and instance '{instance_id_or_name}'" extra_fields = json.loads(instance_obj.extra) if instance_obj.extra else {} - return True, { - "id": instance_obj.id, - "instance_name": instance_obj.instance_name, - "provider_id": provider_id, - "region": extra_fields.get("region", ""), - "status": instance_obj.status - } + return True, {"id": instance_obj.id, "instance_name": instance_obj.instance_name, "provider_id": provider_id, "region": extra_fields.get("region", ""), "status": instance_obj.status} def drop_provider_instances(tenant_id: str, provider_id_or_name: str, instance_id_or_names: list): @@ -665,33 +658,31 @@ def _hybrid_get_instance_models(provider_name: str, instance_id: str): if model_info_map.get(model_record.model_name): model_info_map[model_record.model_name]["model_type"].append(model_record.model_type) else: - model_info_map[model_record.model_name] = { - "status": model_record.status, - "model_type": [model_record.model_type], - "extra": model_record.extra - } + model_info_map[model_record.model_name] = {"status": model_record.status, "model_type": [model_record.model_type], "extra": model_record.extra} llms = factory_info[0].get("llm", []) models = [] for llm in llms: - models.append({ - "name": llm["llm_name"], - "model_type": list( - set(_factory_model_types(llm) + model_info_map.get(llm["llm_name"], {}).get("model_type", [])) - set(model_unsupported_type_map.get(llm["llm_name"], [])) - ), - "max_tokens": llm.get("max_tokens"), - "status": model_info_map.get(llm["llm_name"], {}).get("status", "active"), - }) + models.append( + { + "name": llm["llm_name"], + "model_type": list(set(_factory_model_types(llm) + model_info_map.get(llm["llm_name"], {}).get("model_type", [])) - set(model_unsupported_type_map.get(llm["llm_name"], []))), + "max_tokens": llm.get("max_tokens"), + "status": model_info_map.get(llm["llm_name"], {}).get("status", "active"), + } + ) factory_models = [m["name"] for m in models] for model_name, model_info_dict in model_info_map.items(): if model_name not in factory_models: extra_fields = json.loads(model_info_dict["extra"]) if model_info_dict["extra"] else {} - models.append({ - "name": model_name, - "model_type": set(model_info_dict["model_type"]) - set(model_unsupported_type_map.get(model_name, [])), - "max_tokens": extra_fields.get("max_tokens", 8192), - "status": model_info_dict["status"], - }) + models.append( + { + "name": model_name, + "model_type": set(model_info_dict["model_type"]) - set(model_unsupported_type_map.get(model_name, [])), + "max_tokens": extra_fields.get("max_tokens", 8192), + "status": model_info_dict["status"], + } + ) return True, models @@ -770,18 +761,12 @@ def update_instance_models(tenant_id: str, provider_id_or_name: str, instance_id for model_name in model_names: model_info = model_info_map.get(model_name, {}) TenantModelService.upsert_model_type( - provider_obj.id, - instance_obj.id, - model_name, - { - "add": list(set(model_types) - set(model_info["model_type"])), - "delete": list(set(model_info["model_type"]) - set(model_types)) - } + provider_obj.id, instance_obj.id, model_name, {"add": list(set(model_types) - set(model_info["model_type"])), "delete": list(set(model_info["model_type"]) - set(model_types))} ) return True, "success" -def add_model_to_instance(tenant_id: str, provider_id_or_name: str, instance_id_or_name: str, model_name: str, model_type: str|list[str], max_tokens: int=8192, extra: dict=None): +def add_model_to_instance(tenant_id: str, provider_id_or_name: str, instance_id_or_name: str, model_name: str, model_type: str | list[str], max_tokens: int = 8192, extra: dict = None): provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_id(tenant_id, provider_id_or_name) if not provider_obj: provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_id_or_name) @@ -812,14 +797,11 @@ def add_model_to_instance(tenant_id: str, provider_id_or_name: str, instance_id_ if target_model: extra_fields.update({"is_tools": target_model[0].get("is_tools", False)}) if extra: - extra_fields.update(extra) - TenantModelService.insert( - model_name=model_name, - provider_id=provider_obj.id, - instance_id=instance_obj.id, - model_type=_type, - extra=json.dumps(extra_fields) - ) + if provider_id_or_name == "SoMark" and LLMType.OCR.value in model_type: + extra_fields["ocr_config"] = extra + else: + extra_fields.update(extra) + TenantModelService.insert(model_name=model_name, provider_id=provider_obj.id, instance_id=instance_obj.id, model_type=_type, extra=json.dumps(extra_fields)) return True, "success" @@ -862,9 +844,7 @@ def update_model_status(tenant_id: str, provider_id_or_name: str, instance_id_or return False, f"No instance found for provider '{provider_id_or_name}' and instance '{instance_id_or_name}'" # Check if model record already exists in tenant_model table - model_obj_list = TenantModelService.get_by_provider_id_and_instance_id_and_model_name( - provider_obj.id, instance_obj.id, model_name - ) + model_obj_list = TenantModelService.get_by_provider_id_and_instance_id_and_model_name(provider_obj.id, instance_obj.id, model_name) if model_obj_list: # Model record exists — update its status @@ -892,7 +872,7 @@ def update_model_status(tenant_id: str, provider_id_or_name: str, instance_id_or provider_id=provider_obj.id, instance_id=instance_obj.id, status=status, - extra=json.dumps({"max_tokens": target_llm[0].get("max_tokens", 8192), "is_tools": target_llm[0].get("is_tools", False)}) + extra=json.dumps({"max_tokens": target_llm[0].get("max_tokens", 8192), "is_tools": target_llm[0].get("is_tools", False)}), ) return True, None diff --git a/api/db/joint_services/tenant_model_service.py b/api/db/joint_services/tenant_model_service.py index 96f234754e..4d1c29d80d 100644 --- a/api/db/joint_services/tenant_model_service.py +++ b/api/db/joint_services/tenant_model_service.py @@ -18,7 +18,18 @@ import os import enum import json from common import settings -from common.constants import ActiveStatusEnum, LLMType, MINERU_DEFAULT_CONFIG, MINERU_ENV_KEYS, OPENDATALOADER_DEFAULT_CONFIG, OPENDATALOADER_ENV_KEYS, PADDLEOCR_DEFAULT_CONFIG, PADDLEOCR_ENV_KEYS +from common.constants import ( + ActiveStatusEnum, + LLMType, + MINERU_DEFAULT_CONFIG, + MINERU_ENV_KEYS, + OPENDATALOADER_DEFAULT_CONFIG, + OPENDATALOADER_ENV_KEYS, + PADDLEOCR_DEFAULT_CONFIG, + PADDLEOCR_ENV_KEYS, + SOMARK_DEFAULT_CONFIG, + SOMARK_ENV_KEYS, +) from api.db.services.tenant_llm_service import TenantService from api.db.services.tenant_model_provider_service import TenantModelProviderService from api.db.services.tenant_model_instance_service import TenantModelInstanceService @@ -247,6 +258,11 @@ def get_model_config_from_provider_instance(tenant_id, model_type: str | enum.En "is_tools": model_extra.get("is_tools", is_tool), "max_tokens": model_extra.get("max_tokens") or 8192, } + if provider_name.lower() == "somark": + # SoMark/OCR factories read parser config (somark_*, parse_method, ...) + # from model_config["extra"]; see tenant_llm_service.LLMBundle OCR path. + model_config["extra"] = model_extra.get("ocr_config", model_extra) + if api_key_payload is not None: model_config["api_key_payload"] = api_key_payload @@ -337,6 +353,15 @@ def ensure_opendataloader_from_env(tenant_id: str) -> str | None: ) +def ensure_somark_from_env(tenant_id: str) -> str | None: + return _ensure_ocr_provider_from_env( + tenant_id, + "SoMark", + "somark-from-env", + _collect_env_config(SOMARK_ENV_KEYS, SOMARK_DEFAULT_CONFIG), + ) + + def get_models_by_tenant_and_provider_and_model_type(tenant_id: str, provider_name: str, model_type: str): """ Query TenantModel records by tenant_id, provider_name and model_name. diff --git a/common/constants.py b/common/constants.py index 4bef10c495..87c9ff3f2b 100644 --- a/common/constants.py +++ b/common/constants.py @@ -240,6 +240,19 @@ class ForgettingPolicy(StrEnum): # ENV_MINERU_OUTPUT_DIR = "MINERU_OUTPUT_DIR" # ENV_MINERU_BACKEND = "MINERU_BACKEND" # ENV_MINERU_DELETE_OUTPUT = "MINERU_DELETE_OUTPUT" +# ENV_SOMARK_BASE_URL = "SOMARK_BASE_URL" +# ENV_SOMARK_API_KEY = "SOMARK_API_KEY" +# ENV_SOMARK_IMAGE_FORMAT = "SOMARK_IMAGE_FORMAT" +# ENV_SOMARK_FORMULA_FORMAT = "SOMARK_FORMULA_FORMAT" +# ENV_SOMARK_TABLE_FORMAT = "SOMARK_TABLE_FORMAT" +# ENV_SOMARK_CS_FORMAT = "SOMARK_CS_FORMAT" +# ENV_SOMARK_ENABLE_TEXT_CROSS_PAGE = "SOMARK_ENABLE_TEXT_CROSS_PAGE" +# ENV_SOMARK_ENABLE_TABLE_CROSS_PAGE = "SOMARK_ENABLE_TABLE_CROSS_PAGE" +# ENV_SOMARK_ENABLE_TITLE_LEVEL_RECOGNITION = "SOMARK_ENABLE_TITLE_LEVEL_RECOGNITION" +# ENV_SOMARK_ENABLE_INLINE_IMAGE = "SOMARK_ENABLE_INLINE_IMAGE" +# ENV_SOMARK_ENABLE_TABLE_IMAGE = "SOMARK_ENABLE_TABLE_IMAGE" +# ENV_SOMARK_ENABLE_IMAGE_UNDERSTANDING = "SOMARK_ENABLE_IMAGE_UNDERSTANDING" +# ENV_SOMARK_KEEP_HEADER_FOOTER = "SOMARK_KEEP_HEADER_FOOTER" # ENV_DOCLING_SERVER_URL = "DOCLING_SERVER_URL" # ENV_DOCLING_OUTPUT_DIR = "DOCLING_OUTPUT_DIR" # ENV_DOCLING_DELETE_OUTPUT = "DOCLING_DELETE_OUTPUT" @@ -289,3 +302,34 @@ OPENDATALOADER_ENV_KEYS = ["OPENDATALOADER_APISERVER"] OPENDATALOADER_DEFAULT_CONFIG = { "OPENDATALOADER_APISERVER": "", } + +SOMARK_ENV_KEYS = [ + "SOMARK_BASE_URL", + "SOMARK_API_KEY", + "SOMARK_IMAGE_FORMAT", + "SOMARK_FORMULA_FORMAT", + "SOMARK_TABLE_FORMAT", + "SOMARK_CS_FORMAT", + "SOMARK_ENABLE_TEXT_CROSS_PAGE", + "SOMARK_ENABLE_TABLE_CROSS_PAGE", + "SOMARK_ENABLE_TITLE_LEVEL_RECOGNITION", + "SOMARK_ENABLE_INLINE_IMAGE", + "SOMARK_ENABLE_TABLE_IMAGE", + "SOMARK_ENABLE_IMAGE_UNDERSTANDING", + "SOMARK_KEEP_HEADER_FOOTER", +] +SOMARK_DEFAULT_CONFIG = { + "SOMARK_BASE_URL": "https://somark.tech/api/v1", + "SOMARK_API_KEY": "", + "SOMARK_IMAGE_FORMAT": "url", + "SOMARK_FORMULA_FORMAT": "latex", + "SOMARK_TABLE_FORMAT": "html", + "SOMARK_CS_FORMAT": "image", + "SOMARK_ENABLE_TEXT_CROSS_PAGE": 0, + "SOMARK_ENABLE_TABLE_CROSS_PAGE": 0, + "SOMARK_ENABLE_TITLE_LEVEL_RECOGNITION": 0, + "SOMARK_ENABLE_INLINE_IMAGE": 1, + "SOMARK_ENABLE_TABLE_IMAGE": 1, + "SOMARK_ENABLE_IMAGE_UNDERSTANDING": 1, + "SOMARK_KEEP_HEADER_FOOTER": 0, +} diff --git a/common/parser_config_utils.py b/common/parser_config_utils.py index c73baf6381..df7daafe1b 100644 --- a/common/parser_config_utils.py +++ b/common/parser_config_utils.py @@ -32,5 +32,12 @@ def normalize_layout_recognizer(layout_recognizer_raw: Any) -> tuple[Any, str | elif lowered.endswith("@opendataloader"): parser_model_name = layout_recognizer_raw layout_recognizer = "OpenDataLoader" + elif lowered.endswith("@somark"): + # Keep the full 3-segment form ``@@`` + # produced by the new Tenant LLM Provider UI (#14595); downstream + # ``get_model_config_from_provider_instance`` -> ``split_model_name`` + # expects all three segments to locate the provider/instance row. + parser_model_name = layout_recognizer_raw + layout_recognizer = "SoMark" return layout_recognizer, parser_model_name diff --git a/conf/llm_factories.json b/conf/llm_factories.json index 7fe1712f81..3aeb9b6bfd 100644 --- a/conf/llm_factories.json +++ b/conf/llm_factories.json @@ -7488,6 +7488,14 @@ "status": "1", "llm": [] }, + { + "name": "SoMark", + "logo": "", + "tags": "OCR", + "status": "1", + "rank": "930", + "llm": [] + }, { "name": "n1n", "logo": "", diff --git a/deepdoc/parser/somark_parser.py b/deepdoc/parser/somark_parser.py new file mode 100644 index 0000000000..057b9a886a --- /dev/null +++ b/deepdoc/parser/somark_parser.py @@ -0,0 +1,756 @@ +# +# Copyright 2025 The InfiniFlow Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""SoMark document parser adapter. + +Bridges RAGFlow's PDF parsing pipeline to the SoMark async HTTP API. +Submits a PDF, polls the async task until completion, +then maps SoMark's structured JSON blocks into the (text, layout_type, line_tag) +triples that RAGFlow's downstream chunker expects. +""" + +import json +import logging +import os +import random +import re +import sys +import tempfile +import threading +import time +from io import BytesIO +from os import PathLike +from pathlib import Path +from typing import Callable, Optional + +import numpy as np +import pdfplumber +import requests +from PIL import Image +from enum import StrEnum + +from deepdoc.parser.pdf_parser import RAGFlowPdfParser +from deepdoc.parser.utils import extract_pdf_outlines + +from common.constants import MAXIMUM_PAGE_NUMBER + +LOCK_KEY_pdfplumber = "global_shared_lock_pdfplumber" +if LOCK_KEY_pdfplumber not in sys.modules: + sys.modules[LOCK_KEY_pdfplumber] = threading.Lock() + + +class SoMarkBlockType(StrEnum): + """All block.type values returned by SoMark JSON output.""" + + TEXT = "text" + TITLE = "title" + FIGURE = "figure" + FIGURE_CAPTION = "figure_caption" + TABLE = "table" + TABLE_CAPTION = "table_caption" + HEADER = "header" + FOOTER = "footer" + FOOTNOTE = "footnote" + SIDER = "sider" + CATE = "cate" + CATE_ITEM = "cate_item" + CODE = "code" + CHOICE = "choice" + BLANK = "blank" + QRCODE = "qrcode" + STAMP = "stamp" + REFERENCE = "reference" + EQUATION = "equation" + CS = "cs" + CS_EQUATION = "cs_equation" + + +# Map each SoMark type to RAGFlow's internal layout type. +# Internal types used downstream: text / table / image / equation / code / discarded. +SOMARK_TYPE_TO_RAGFLOW = { + SoMarkBlockType.TEXT: "text", + SoMarkBlockType.TITLE: "text", + SoMarkBlockType.FIGURE: "image", + SoMarkBlockType.FIGURE_CAPTION: "text", + SoMarkBlockType.TABLE: "table", + SoMarkBlockType.TABLE_CAPTION: "text", + SoMarkBlockType.FOOTNOTE: "text", + SoMarkBlockType.SIDER: "text", + SoMarkBlockType.CODE: "code", + SoMarkBlockType.CHOICE: "text", + SoMarkBlockType.REFERENCE: "text", + SoMarkBlockType.EQUATION: "equation", + SoMarkBlockType.CS: "image", + SoMarkBlockType.CS_EQUATION: "text", + SoMarkBlockType.QRCODE: "image", + SoMarkBlockType.STAMP: "image", + # header/footer resolved at runtime based on keep_header_footer flag. + # cate/cate_item/blank are always discarded. +} + +# Block types that are always dropped (TOC noise, empty form fields). +ALWAYS_DISCARDED = { + SoMarkBlockType.CATE, + SoMarkBlockType.CATE_ITEM, + SoMarkBlockType.BLANK, +} + + +class SoMarkAPIError(RuntimeError): + """Raised when SoMark API returns a non-zero ``code`` or HTTP failure.""" + + +class SoMarkParser(RAGFlowPdfParser): + """Parse a PDF via SoMark's async HTTP API and convert blocks to RAGFlow sections.""" + + SUBMIT_PATH = "/parse/async" + CHECK_PATH = "/parse/async_check" + USAGE_PATH = "/usage" + + # /usage quota check only works in SaaS; private deployments fall back + # to a generic HEAD health check. + SAAS_BASE_URL = "https://somark.tech/api/v1" + USAGE_REQUEST_TIMEOUT = 10 # /usage request timeout + + # SoMark error codes + QPS_LIMIT_CODE = 1124 # rate limited; retry with backoff when hit during submission + INVALID_API_KEY_CODE = 1107 # returned by /usage check when API key is invalid + + # Submission phase: retry "concurrency slots full" rejections within a fixed budget + SUBMIT_BUDGET_SECONDS = 10 * 60 # total submission retry budget (10 min) + SUBMIT_BACKOFF_BASE_SECONDS = 1.0 # initial backoff interval + SUBMIT_BACKOFF_MAX_SECONDS = 10.0 # max single backoff interval + SUBMIT_BACKOFF_JITTER_SECONDS = 0.5 # jitter to avoid thundering herd from concurrent callers + SUBMIT_REQUEST_TIMEOUT = 60 # single submit request timeout + + # Polling phase: keep querying task status until success / failure / budget exhausted + POLL_BUDGET_SECONDS = 10 * 60 # max time to wait for a single task + POLL_INTERVAL_BASE_SECONDS = 2.0 # initial polling interval + POLL_INTERVAL_MAX_SECONDS = 10.0 # max polling interval for long-running tasks + POLL_INTERVAL_GROWTH = 1.5 # multiplier applied after each poll + POLL_REQUEST_TIMEOUT = 30 # single poll request timeout + + def __init__( + self, + base_url: str, + api_key: str = "", + *, + image_format: str = "url", + formula_format: str = "latex", + table_format: str = "html", + cs_format: str = "image", + enable_text_cross_page: bool = False, + enable_table_cross_page: bool = False, + enable_title_level_recognition: bool = False, + enable_inline_image: bool = False, + enable_table_image: bool = True, + enable_image_understanding: bool = True, + keep_header_footer: bool = False, + ): + self.base_url = base_url.strip().rstrip("/") + # Intentionally NOT stripping: caller may want to pass raw key as-is + # (e.g. for verification where whitespace would also be reported back). + self.api_key = api_key + self.element_formats = { + "image": image_format.strip().lower(), + "formula": formula_format.strip().lower(), + "table": table_format.strip().lower(), + "cs": cs_format.strip().lower(), + } + self.feature_config = { + "enable_text_cross_page": bool(enable_text_cross_page), + "enable_table_cross_page": bool(enable_table_cross_page), + "enable_title_level_recognition": bool(enable_title_level_recognition), + "enable_inline_image": bool(enable_inline_image), + "enable_table_image": bool(enable_table_image), + "enable_image_understanding": bool(enable_image_understanding), + "keep_header_footer": bool(keep_header_footer), + } + self.outlines: list = [] + self.logger = logging.getLogger(self.__class__.__name__) + + # --------------------------------------------------------------------- + # Reachability check + # --------------------------------------------------------------------- + @staticmethod + def _is_http_endpoint_valid(url: str, timeout: int = 5) -> bool: + try: + response = requests.head(url, timeout=timeout, allow_redirects=True) + return response.status_code < 500 + except Exception: + return False + + def check_installation(self) -> tuple[bool, str]: + if not self.base_url: + return False, "[SoMark] SOMARK_BASE_URL not configured." + if not self.base_url.startswith(("http://", "https://")): + return False, "[SoMark] SOMARK_BASE_URL must start with http:// or https://." + + # SaaS deployment: hit /usage to verify API key validity and remaining quota. + if self.base_url == self.SAAS_BASE_URL: + return self._check_saas_usage() + + # Private deployment: use a cheap HEAD health check. + if not self._is_http_endpoint_valid(self.base_url): + return False, f"[SoMark] server unreachable: {self.base_url}" + return True, "" + + def _check_saas_usage(self) -> tuple[bool, str]: + """Verify api_key and remaining quota against the hosted SoMark service. + + Treats two specific business outcomes as user-facing errors: + - ``code == 1107``: invalid API key. + - ``code == 0`` but both ``remaining_paid_pages`` and + ``remaining_free_pages_this_month`` are 0: out of parse quota. + """ + url = f"{self.base_url}{self.USAGE_PATH}" + data = self._auth_field() + try: + resp = requests.post(url, data=data, timeout=self.USAGE_REQUEST_TIMEOUT) + except requests.RequestException as exc: + return False, f"[SoMark] usage check failed: {exc}" + + if resp.status_code >= 500: + return False, (f"[SoMark] usage HTTP {resp.status_code}: {resp.text[:200]}") + + try: + body = resp.json() + except ValueError: + return False, (f"[SoMark] usage non-JSON response ({resp.status_code}): {resp.text[:200]}") + + code = body.get("code") + message = body.get("message") or "" + + if code == self.INVALID_API_KEY_CODE: + return False, f"[SoMark] {message or 'Invalid API key'}" + + if code != 0: + return False, f"[SoMark] usage error code={code} message={message}" + + usage = body.get("data") or {} + paid = usage.get("remaining_paid_pages") or 0 + free = usage.get("remaining_free_pages_this_month") or 0 + if paid == 0 and free == 0: + return False, ("[SoMark] insufficient parse pages (remaining_paid_pages=0, remaining_free_pages_this_month=0)") + + return True, "" + + # --------------------------------------------------------------------- + # HTTP helpers + # --------------------------------------------------------------------- + def _auth_field(self) -> dict: + """Return the api_key multipart field if configured; empty dict otherwise. + + SoMark's hosted API requires ``api_key``. Local deployments reject the + field outright, so we omit it entirely when blank. + """ + return {"api_key": self.api_key} if self.api_key else {} + + def _submit_task(self, pdf_path: Path, callback: Optional[Callable] = None) -> str: + url = f"{self.base_url}{self.SUBMIT_PATH}" + data = { + "output_formats": ["json"], + "element_formats": json.dumps(self.element_formats, ensure_ascii=False), + "feature_config": json.dumps(self.feature_config, ensure_ascii=False), + } + data.update(self._auth_field()) + + safe_keys = [k for k in data if k != "api_key"] + self.logger.info(f"[SoMark] submit {url} keys={safe_keys} has_api_key={bool(self.api_key)}") + if callback: + callback(0.20, f"[SoMark] submitting task to {url}") + + deadline = time.monotonic() + self.SUBMIT_BUDGET_SECONDS + attempt = 0 + + while True: + try: + with open(pdf_path, "rb") as fh: + files = {"file": (pdf_path.name, fh, "application/pdf")} + # multipart fields with list values must be sent as repeated tuples + form_data = [] + for key, value in data.items(): + if isinstance(value, list): + for v in value: + form_data.append((key, str(v))) + else: + form_data.append((key, str(value))) + resp = requests.post( + url, + files=files, + data=form_data, + timeout=self.SUBMIT_REQUEST_TIMEOUT, + ) + except requests.RequestException as exc: + raise SoMarkAPIError(f"[SoMark] submit failed: {exc}") from exc + + # Inline parsing so the QPS-limit code can be distinguished from + # other business errors before raising. + if resp.status_code >= 500: + raise SoMarkAPIError(f"[SoMark] submit HTTP {resp.status_code}: {resp.text[:200]}") + try: + body = resp.json() + except ValueError as exc: + raise SoMarkAPIError(f"[SoMark] submit non-JSON response ({resp.status_code}): {resp.text[:200]}") from exc + + code = body.get("code") + if code == 0: + task_id = (body.get("data") or {}).get("task_id") + if not task_id: + raise SoMarkAPIError(f"[SoMark] submit returned no task_id: {body}") + self.logger.info(f"[SoMark] task submitted, task_id={task_id} attempts={attempt + 1}") + return task_id + + # QPS / concurrency rejection: exponential backoff within budget. + if code == self.QPS_LIMIT_CODE: + backoff = min( + self.SUBMIT_BACKOFF_BASE_SECONDS * (2**attempt), + self.SUBMIT_BACKOFF_MAX_SECONDS, + ) + wait = backoff + random.random() * self.SUBMIT_BACKOFF_JITTER_SECONDS + if time.monotonic() + wait > deadline: + raise SoMarkAPIError("[SoMark] submit blocked by QPS limit; retry budget exhausted") + self.logger.info( + "[SoMark] submit hit QPS limit, retrying in %.2fs (attempt=%d)", + wait, + attempt + 1, + ) + if callback: + callback( + 0.20, + f"[SoMark] busy (QPS limit), backing off {wait:.2f}s before retry", + ) + time.sleep(wait) + attempt += 1 + continue + + # Any other non-zero code: not retryable. + raise SoMarkAPIError(f"[SoMark] submit business error code={code} message={body.get('message')}") + + def _poll_task(self, task_id: str, callback: Optional[Callable] = None) -> dict: + url = f"{self.base_url}{self.CHECK_PATH}" + deadline = time.monotonic() + self.POLL_BUDGET_SECONDS + interval = self.POLL_INTERVAL_BASE_SECONDS + started_at = time.monotonic() + attempt = 0 + + while time.monotonic() < deadline: + # Sleep first: the task was just submitted, an immediate poll is wasteful. + time.sleep(interval) + attempt += 1 + + data = {"task_id": task_id} + data.update(self._auth_field()) + try: + resp = requests.post(url, data=data, timeout=self.POLL_REQUEST_TIMEOUT) + except requests.RequestException as exc: + raise SoMarkAPIError(f"[SoMark] poll request failed: {exc}") from exc + + body = self._parse_json_body(resp, "poll") + payload = body.get("data") or {} + status = payload.get("status") + elapsed = time.monotonic() - started_at + + if status == "SUCCESS": + self.logger.info(f"[SoMark] task {task_id} completed after {attempt} poll(s) in {elapsed:.1f}s") + result = payload.get("result") + if not result: + raise SoMarkAPIError(f"[SoMark] SUCCESS but no result: {body}") + return result + if status == "FAILED": + raise SoMarkAPIError(f"[SoMark] task {task_id} FAILED: {body.get('message')}") + + if callback and attempt % 5 == 0: + callback( + 0.40, + f"[SoMark] still {status}, polled {attempt} time(s) (elapsed={elapsed:.1f}s, next in {interval:.1f}s)", + ) + + interval = min( + interval * self.POLL_INTERVAL_GROWTH, + self.POLL_INTERVAL_MAX_SECONDS, + ) + + raise SoMarkAPIError(f"[SoMark] task {task_id} timed out after {self.POLL_BUDGET_SECONDS}s while waiting") + + @staticmethod + def _parse_json_body(resp: requests.Response, stage: str) -> dict: + if resp.status_code >= 500: + raise SoMarkAPIError(f"[SoMark] {stage} HTTP {resp.status_code}: {resp.text[:200]}") + try: + body = resp.json() + except ValueError as exc: + raise SoMarkAPIError(f"[SoMark] {stage} non-JSON response ({resp.status_code}): {resp.text[:200]}") from exc + + code = body.get("code") + if code != 0: + raise SoMarkAPIError(f"[SoMark] {stage} business error code={code} message={body.get('message')}") + return body + + # --------------------------------------------------------------------- + # Page image rendering + # --------------------------------------------------------------------- + def __images__( + self, + fnm, + zoomin: int = 1, + page_from: int = 0, + page_to: int = MAXIMUM_PAGE_NUMBER, + callback=None, + ): + self.page_from = page_from + self.page_to = page_to + try: + ctx = pdfplumber.open(fnm) if isinstance(fnm, (str, PathLike)) else pdfplumber.open(BytesIO(fnm)) + with ctx as pdf: + self.pdf = pdf + self.page_images = [p.to_image(resolution=72 * zoomin, antialias=True).original for _, p in enumerate(self.pdf.pages[page_from:page_to])] + except Exception as exc: + self.page_images = None + self.total_page = 0 + self.logger.exception(exc) + + # --------------------------------------------------------------------- + # Position tagging (compatible with RAGFlow's extract_positions/crop) + # --------------------------------------------------------------------- + def _line_tag(self, bx: dict) -> str: + """Build a ``@@page\\tx0\\tx1\\ty0\\ty1##`` tag. + + bx requires keys: ``page_idx`` (0-based), ``bbox`` ([x1,y1,x2,y2] in + SoMark's reported pixel coordinates), ``page_size`` ({h,w}). + """ + page_idx = bx.get("page_idx", 0) + pn = [page_idx + 1] + bbox = bx.get("bbox") or (0, 0, 0, 0) + if len(bbox) != 4: + bbox = (0, 0, 0, 0) + x0, top, x1, bott = bbox + page_size = bx.get("page_size") or {} + src_w = page_size.get("w") or 1 + src_h = page_size.get("h") or 1 + + if x0 > x1: + x0, x1 = x1, x0 + if top > bott: + top, bott = bott, top + + if hasattr(self, "page_images") and self.page_images and len(self.page_images) > page_idx: + page_width, page_height = self.page_images[page_idx].size + x0 = (x0 / src_w) * page_width + x1 = (x1 / src_w) * page_width + top = (top / src_h) * page_height + bott = (bott / src_h) * page_height + + return "@@{}\t{:.1f}\t{:.1f}\t{:.1f}\t{:.1f}##".format("-".join(str(p) for p in pn), x0, x1, top, bott) + + @staticmethod + def extract_positions(txt: str): + poss = [] + for tag in re.findall(r"@@[0-9-]+\t[0-9.\t]+##", txt): + pn, left, right, top, bottom = tag.strip("#").strip("@").split("\t") + left, right, top, bottom = ( + float(left), + float(right), + float(top), + float(bottom), + ) + poss.append(([int(p) - 1 for p in pn.split("-")], left, right, top, bottom)) + return poss + + def crop(self, text, ZM=1, need_position=False): + """Image crop based on tags.""" + imgs = [] + poss = self.extract_positions(text) + if not poss: + return (None, None) if need_position else None + if not getattr(self, "page_images", None): + self.logger.warning("[SoMark] crop called without page images; skip.") + return (None, None) if need_position else None + + page_count = len(self.page_images) + filtered = [] + for pns, left, right, top, bottom in poss: + valid_pns = [p for p in pns if 0 <= p < page_count] + if valid_pns: + filtered.append((valid_pns, left, right, top, bottom)) + if not filtered: + return (None, None) if need_position else None + poss = filtered + + max_width = max(np.max([right - left for (_, left, right, _, _) in poss]), 6) + GAP = 6 + first = poss[0] + poss.insert( + 0, + ( + [first[0][0]], + first[1], + first[2], + max(0, first[3] - 120), + max(first[3] - GAP, 0), + ), + ) + last = poss[-1] + last_pn = last[0][-1] + if not (0 <= last_pn < page_count): + return (None, None) if need_position else None + last_h = self.page_images[last_pn].size[1] + poss.append( + ( + [last_pn], + last[1], + last[2], + min(last_h, last[4] + GAP), + min(last_h, last[4] + 120), + ) + ) + + positions = [] + for ii, (pns, left, right, top, bottom) in enumerate(poss): + right = left + max_width + if bottom <= top: + bottom = top + 2 + for pn in pns[1:]: + if 0 <= pn - 1 < page_count: + bottom += self.page_images[pn - 1].size[1] + if not (0 <= pns[0] < page_count): + continue + base_img = self.page_images[pns[0]] + x0, y0, x1, y1 = ( + int(left), + int(top), + int(right), + int(min(bottom, base_img.size[1])), + ) + if x0 > x1: + x0, x1 = x1, x0 + if y0 > y1: + y0, y1 = y1, y0 + if x1 <= x0 or y1 <= y0: + continue + imgs.append(base_img.crop((x0, y0, x1, y1))) + if 0 < ii < len(poss) - 1: + positions.append((pns[0] + self.page_from, x0, x1, y0, y1)) + bottom -= base_img.size[1] + for pn in pns[1:]: + if not (0 <= pn < page_count): + continue + page = self.page_images[pn] + x0, y0, x1, y1 = ( + int(left), + 0, + int(right), + int(min(bottom, page.size[1])), + ) + if x0 > x1: + x0, x1 = x1, x0 + if y0 > y1: + y0, y1 = y1, y0 + if x1 <= x0 or y1 <= y0: + bottom -= page.size[1] + continue + imgs.append(page.crop((x0, y0, x1, y1))) + if 0 < ii < len(poss) - 1: + positions.append((pn + self.page_from, x0, x1, y0, y1)) + bottom -= page.size[1] + + if not imgs: + return (None, None) if need_position else None + + height = sum(img.size[1] + GAP for img in imgs) + width = int(np.max([i.size[0] for i in imgs])) + pic = Image.new("RGB", (width, int(height)), (245, 245, 245)) + offset = 0 + for ii, img in enumerate(imgs): + if ii == 0 or ii + 1 == len(imgs): + img = img.convert("RGBA") + overlay = Image.new("RGBA", img.size, (0, 0, 0, 0)) + overlay.putalpha(128) + img = Image.alpha_composite(img, overlay).convert("RGB") + pic.paste(img, (0, int(offset))) + offset += img.size[1] + GAP + + return (pic, positions) if need_position else pic + + # --------------------------------------------------------------------- + # SoMark JSON -> RAGFlow sections + # --------------------------------------------------------------------- + def _resolve_internal_type(self, block_type: str) -> Optional[str]: + """Resolve the RAGFlow internal layout type, or ``None`` to discard. + + Header/footer obey ``keep_header_footer``; cate/cate_item/blank always drop. + Unknown SoMark types fall back to ``text`` to avoid silent loss. + """ + if block_type in ALWAYS_DISCARDED: + return None + if block_type == SoMarkBlockType.HEADER or block_type == SoMarkBlockType.FOOTER: + return "text" if self.feature_config.get("keep_header_footer") else None + return SOMARK_TYPE_TO_RAGFLOW.get(block_type, "text") + + @staticmethod + def _block_text(block: dict, internal_type: str) -> str: + """Extract the textual payload for a block. + + ``image``-typed blocks contribute no text (image only); everything else + falls back to ``content``. For ``title`` blocks with title_level we prepend + markdown-style hashes so downstream tokenization preserves hierarchy. + """ + if internal_type == "image": + return "" + content = (block.get("content") or "").strip() + if block.get("type") == SoMarkBlockType.TITLE.value: + level = block.get("title_level") + if isinstance(level, int) and 1 <= level <= 6: + content = ("#" * level) + " " + content + return content + + def _transfer_to_sections(self, pages: list[dict], parse_method: Optional[str] = None) -> list[tuple]: + # MinerU contract: manual/pipeline callers (the rag/flow DAG) want typed + # 3-tuples ``(text, layout_type, line_tag)`` so the consumer can set + # box["layout_type"] and crop via the separate position field; every other + # caller (naive.py standard chunking) wants 2-tuples ``(text, line_tag)`` + # that naive_merge consumes directly. + typed = parse_method in {"manual", "pipeline"} + sections: list[tuple] = [] + image_seq = 0 + for page in pages or []: + page_idx = page.get("page_num", 0) + page_size = page.get("page_size") or {} + for block in page.get("blocks") or []: + btype = block.get("type") + internal = self._resolve_internal_type(btype) + if internal is None: + continue + # Inject page_idx so _line_tag can compute coords. + bbox = block.get("bbox") + tag_input = { + "page_idx": page_idx, + "bbox": bbox, + "page_size": page_size, + } + if internal == "image": + # Align with MinerU: the figure is recovered by cropping the + # locally rendered page region via crop(), not from img_url. + if not bbox or len(bbox) != 4: + continue # no geometry -> nothing to crop + line_tag = self._line_tag(tag_input) + image_seq += 1 + caption = (block.get("content") or "").strip() + label = caption or f"{btype} {image_seq}" + if typed: + # 3-tuple: layout_type + a real (separate) position field; + # keep the caption in text so figure understanding can be + # embedded and retrieved while crop() still uses line_tag. + sections.append((label, internal, line_tag)) + else: + # 2-tuple (naive.py): the chunk id is hash(content + doc_id), + # so an empty-text image chunk would collide across every + # figure and all but one would be dropped on upsert. Give it a + # non-empty, unique caption (SoMark image-understanding text if + # present, else " ") so each figure gets a distinct + # id. The tag is appended so tokenize_chunks() -> crop() can + # still recover the figure; remove_tag() then strips it, leaving + # the caption as the chunk text. + sections.append((f"{label}{line_tag}", "")) + continue + text = self._block_text(block, internal) + if not text: + continue + line_tag = self._line_tag(tag_input) + if typed: + sections.append((text, internal, line_tag)) + else: + sections.append((text, line_tag)) + + return sections + + def _transfer_to_tables(self, pages: list[dict]) -> list: + # Tables are inlined as HTML in section text; no separate table extraction. + return [] + + # --------------------------------------------------------------------- + # Public entry point + # --------------------------------------------------------------------- + def parse_pdf( + self, + filepath: str | PathLike[str], + binary: BytesIO | bytes | None = None, + callback: Optional[Callable] = None, + parse_method: Optional[str] = None, + **kwargs, + ) -> tuple: + self.outlines = extract_pdf_outlines(binary if binary is not None else filepath) + + # Normalize input to a real PDF file on disk. + temp_pdf: Optional[Path] = None + if binary: + tmp_dir = Path(tempfile.mkdtemp(prefix="somark_bin_pdf_")) + file_name = Path(filepath).stem.replace(" ", "") + ".pdf" + temp_pdf = tmp_dir / file_name + with open(temp_pdf, "wb") as f: + f.write(binary.getvalue() if isinstance(binary, BytesIO) else binary) + pdf_path = temp_pdf + else: + pdf_path = Path(filepath) + if not pdf_path.exists(): + if callback: + callback(-1, f"[SoMark] PDF not found: {pdf_path}") + raise FileNotFoundError(f"[SoMark] PDF not found: {pdf_path}") + + if callback: + callback(0.10, f"[SoMark] using {pdf_path.name}") + + # Render page images locally so _line_tag/crop can map bbox to pixels. + self.__images__(pdf_path, zoomin=1) + + try: + ok, reason = self.check_installation() + if not ok: + raise SoMarkAPIError(reason) + + task_id = self._submit_task(pdf_path, callback=callback) + result = self._poll_task(task_id, callback=callback) + + outputs = (result or {}).get("outputs") or {} + json_payload = outputs.get("json") or {} + pages = json_payload.get("pages") or [] + if not pages: + self.logger.warning("[SoMark] empty pages in response; nothing to chunk") + + if callback: + callback( + 0.75, + f"[SoMark] parsed {sum(len(p.get('blocks') or []) for p in pages)} blocks across {len(pages)} pages", + ) + + sections = self._transfer_to_sections(pages, parse_method) + tables = self._transfer_to_tables(pages) + return sections, tables + finally: + if temp_pdf and temp_pdf.exists(): + try: + temp_pdf.unlink() + temp_pdf.parent.rmdir() + except Exception: + pass + + +if __name__ == "__main__": + parser = SoMarkParser( + base_url=os.environ.get("SOMARK_BASE_URL", "https://somark.tech/api/v1"), + api_key=os.environ.get("SOMARK_API_KEY", ""), + ) + ok, reason = parser.check_installation() + print("SoMark available:", ok, reason) diff --git a/rag/app/naive.py b/rag/app/naive.py index a1903e58ff..fe6fc2d804 100644 --- a/rag/app/naive.py +++ b/rag/app/naive.py @@ -66,21 +66,21 @@ from rag.nlp import ( def _is_short_header(text, max_tokens=50): """ Check if text is a short markdown header. - + Args: text: The text to check max_tokens: Maximum tokens for a header to be considered "short" - + Returns: bool: True if text is a short markdown header, False otherwise """ if not text or not text.strip(): return False - + # Check if it matches markdown header pattern: 1-6 # followed by space if not re.match(r"^#{1,6}\s+", text.strip()): return False - + # Check if token count is below threshold return num_tokens_from_string(text) < max_tokens @@ -301,6 +301,60 @@ def by_paddleocr( return None, None, None +def by_somark( + filename, + binary=None, + from_page=0, + to_page=MAXIMUM_PAGE_NUMBER, + lang="Chinese", + callback=None, + pdf_cls=None, + parse_method: str = "raw", + somark_llm_name: str | None = None, + tenant_id: str | None = None, + **kwargs, +): + pdf_parser = None + if tenant_id: + if not somark_llm_name: + try: + from api.db.joint_services.tenant_model_service import ensure_somark_from_env + + somark_llm_name = ensure_somark_from_env(tenant_id) + except Exception as e: + logging.warning(f"fallback to env somark: {e}") + + if somark_llm_name: + try: + try: + ocr_model_config = get_model_config_from_provider_instance(tenant_id, LLMType.OCR, somark_llm_name) + except Exception: + if "@" in somark_llm_name: + raise + from api.db.services.tenant_llm_service import TenantLLMService + + ocr_model_config = TenantLLMService.get_model_config(tenant_id, LLMType.OCR.value, somark_llm_name) + ocr_model = LLMBundle(tenant_id=tenant_id, model_config=ocr_model_config, lang=lang) + pdf_parser = ocr_model.mdl + sections, tables = pdf_parser.parse_pdf( + filepath=filename, + binary=binary, + callback=callback, + parse_method=parse_method, + **kwargs, + ) + return sections, tables, pdf_parser + except Exception as e: + logging.error(f"Failed to parse pdf via LLMBundle SoMark ({somark_llm_name}): {e}") + if callback: + callback(-1, f"Failed to parse pdf via SoMark ({somark_llm_name}): {e}") + return None, None, None + + if callback: + callback(-1, "SoMark not found.") + return None, None, None + + def by_plaintext(filename, binary=None, from_page=0, to_page=MAXIMUM_PAGE_NUMBER, callback=None, **kwargs): layout_recognizer = (kwargs.get("layout_recognizer") or "").strip() if (not layout_recognizer) or (layout_recognizer == "Plain Text"): @@ -328,6 +382,7 @@ PARSERS = { "opendataloader": by_opendataloader, "tcadp parser": by_tcadp, "paddleocr": by_paddleocr, + "somark": by_somark, "plaintext": by_plaintext, # default } @@ -954,6 +1009,7 @@ def chunk(filename, binary=None, from_page=0, to_page=MAXIMUM_PAGE_NUMBER, lang= mineru_llm_name=parser_model_name, paddleocr_llm_name=parser_model_name, opendataloader_llm_name=opendataloader_llm_name, + somark_llm_name=parser_model_name, **kwargs, ) sections = _normalize_section_text_for_rtl_presentation_forms(sections) @@ -964,7 +1020,7 @@ def chunk(filename, binary=None, from_page=0, to_page=MAXIMUM_PAGE_NUMBER, lang= if table_context_size or image_context_size: tables = append_context2table_image4pdf(sections, tables, image_context_size) - if name in ["tcadp", "docling", "mineru", "paddleocr", "opendataloader"]: + if name in ["tcadp", "docling", "mineru", "paddleocr", "opendataloader", "somark"]: if int(parser_config.get("chunk_token_num", 0)) <= 0: parser_config["chunk_token_num"] = 0 @@ -1006,9 +1062,9 @@ def chunk(filename, binary=None, from_page=0, to_page=MAXIMUM_PAGE_NUMBER, lang= callback(0.1, "Start to parse.") sections = TxtParser()(filename, binary, parser_config.get("chunk_token_num", 128), parser_config.get("delimiter", "\n!?;。;!?")) sections = _normalize_section_text_for_rtl_presentation_forms(sections) - print("\n", "-"*150, "\n") + print("\n", "-" * 150, "\n") print(sections) - print("\n", "-"*150, "\n") + print("\n", "-" * 150, "\n") callback(0.8, "Finish parsing.") elif re.search(r"\.(md|markdown|mdx)$", filename, re.IGNORECASE): @@ -1114,7 +1170,7 @@ def chunk(filename, binary=None, from_page=0, to_page=MAXIMUM_PAGE_NUMBER, lang= st = timer() overlapped_percent = normalize_overlapped_percent(parser_config.get("overlapped_percent", 0)) - + if is_markdown: merged_chunks = [] merged_images = [] @@ -1199,10 +1255,7 @@ def chunk(filename, binary=None, from_page=0, to_page=MAXIMUM_PAGE_NUMBER, lang= # Attach PDF outline as transient metadata on the first chunk. # task_executor.py will extract and persist it as document metadata. if res and pdf_parser and getattr(pdf_parser, "outlines", None): - res[0]["__outline__"] = [ - {"title": title, "depth": depth} - for title, depth, *_ in pdf_parser.outlines - ] + res[0]["__outline__"] = [{"title": title, "depth": depth} for title, depth, *_ in pdf_parser.outlines] return res diff --git a/rag/flow/parser/parser.py b/rag/flow/parser/parser.py index 970cfcb498..920df678a6 100644 --- a/rag/flow/parser/parser.py +++ b/rag/flow/parser/parser.py @@ -254,7 +254,7 @@ class ParserParam(ProcessParamBase): pdf_parse_method = pdf_config.get("parse_method", "") self.check_empty(pdf_parse_method, "Parse method abnormal.") - if pdf_parse_method.lower() not in ["deepdoc", "plain_text", "mineru", "docling", "opendataloader", "tcadp parser", "paddleocr"]: + if pdf_parse_method.lower() not in ["deepdoc", "plain_text", "mineru", "docling", "opendataloader", "tcadp parser", "paddleocr", "somark"]: self.check_empty(pdf_config.get("lang", ""), "PDF VLM language") pdf_output_format = pdf_config.get("output_format", "") @@ -348,6 +348,13 @@ class Parser(ProcessBase): elif lowered.endswith("@paddleocr"): parser_model_name = raw_parse_method parse_method = "PaddleOCR" + elif lowered.endswith("@somark"): + # Keep the full 3-segment ``@@`` + # form produced by the new Tenant LLM Provider UI (#14595); + # ``get_model_config_from_provider_instance`` -> ``split_model_name`` + # downstream requires all three segments. + parser_model_name = raw_parse_method + parse_method = "SoMark" # DeepDOC returns structured page boxes directly. if parse_method.lower() == "deepdoc": @@ -498,6 +505,59 @@ class Parser(ProcessBase): pass bboxes.append(box) + elif parse_method.lower() == "somark": + + def resolve_somark_llm_name(): + configured = parser_model_name or conf.get("somark_llm_name") + if configured: + return configured + tenant_id = self._canvas._tenant_id + if not tenant_id: + return None + from api.db.joint_services.tenant_model_service import ensure_somark_from_env + + return ensure_somark_from_env(tenant_id) + + parser_model_name = resolve_somark_llm_name() + if not parser_model_name: + raise RuntimeError("SoMark model not configured. Please add SoMark in Model Providers or set SOMARK_* env.") + + tenant_id = self._canvas._tenant_id + try: + ocr_model_config = get_model_config_from_provider_instance(tenant_id, LLMType.OCR, parser_model_name) + except Exception: + if "@" in parser_model_name: + raise + from api.db.services.tenant_llm_service import TenantLLMService + + ocr_model_config = TenantLLMService.get_model_config(tenant_id, LLMType.OCR.value, parser_model_name) + ocr_model = LLMBundle(tenant_id, ocr_model_config) + pdf_parser = ocr_model.mdl + + lines, _ = pdf_parser.parse_pdf( + filepath=name, + binary=blob, + callback=self.callback, + parse_method="pipeline", + ) + bboxes = [] + for item in lines or []: + if not isinstance(item, tuple) or len(item) < 3: + continue + text, layout_type, poss = item[0], item[1], item[2] + box = { + "text": text, + "layout_type": layout_type or "text", + } + if isinstance(poss, str) and poss: + positions = [[pos[0][-1] + 1, *pos[1:]] for pos in pdf_parser.extract_positions(poss)] + if positions: + box["positions"] = positions + image = pdf_parser.crop(poss, 1) + if image is not None: + box["image"] = image + bboxes.append(box) + elif parse_method.lower() == "tcadp parser": # ADP is a document parsing tool using Tencent Cloud API table_result_type = conf.get("table_result_type", "1") @@ -635,12 +695,15 @@ class Parser(ProcessBase): layout = re.sub(r"\s+", " ", raw_layout) if has_layout else "text" b["layout_type"] = layout if conf.get("remove_header_footer") and re.search(r"(header|footer|number)", raw_layout, re.I): - continue + continue if flatten_media_to_text: b["doc_type_kwd"] = "text" elif layout == "table": b["doc_type_kwd"] = "table" - elif layout == "figure": + elif layout in {"figure", "image"}: + # Markdown writer below only renders layout_type == "figure"; + # normalize "image" so SoMark/PaddleOCR media render inline. + b["layout_type"] = "figure" b["doc_type_kwd"] = "image" elif not has_layout and b.get("image") is not None: b["doc_type_kwd"] = "image" @@ -786,7 +849,7 @@ class Parser(ProcessBase): conf = self._param.setups["docx"] self.set_output("output_format", conf["output_format"]) flatten_media_to_text = conf.get("flatten_media_to_text") - + if re.search(r"\.doc$", name, re.IGNORECASE): self.set_output("file", {**kwargs.get("file", {}), "outlines": []}) try: @@ -975,11 +1038,7 @@ class Parser(ProcessBase): # If multiple images found, combine them using concat_img combined_image = reduce(concat_img, images) if len(images) > 1 else images[0] json_result["image"] = combined_image - json_result["doc_type_kwd"] = ( - "text" - if flatten_media_to_text or json_result.get("image") is None - else "image" - ) + json_result["doc_type_kwd"] = "text" if flatten_media_to_text or json_result.get("image") is None else "image" json_results.append(json_result) for table in tables: @@ -1009,7 +1068,7 @@ class Parser(ProcessBase): self.callback(random.randint(1, 5) / 100.0, "Start to work on a text or code file.") conf = self._param.setups["text&code"] self.set_output("output_format", conf["output_format"]) - + sections = TxtParser()( name, blob, diff --git a/rag/llm/ocr_model.py b/rag/llm/ocr_model.py index 42e67d1bd9..a3ddaf091c 100644 --- a/rag/llm/ocr_model.py +++ b/rag/llm/ocr_model.py @@ -21,6 +21,7 @@ from typing import Any, Optional from deepdoc.parser.mineru_parser import MinerUParser from deepdoc.parser.opendataloader_parser import OpenDataLoaderParser from deepdoc.parser.paddleocr_parser import PaddleOCRParser +from deepdoc.parser.somark_parser import SoMarkParser class Base: @@ -213,3 +214,119 @@ class OpenDataLoaderOcrModel(Base, OpenDataLoaderParser): **kwargs, ) return sections, tables + + +class SoMarkOcrModel(Base, SoMarkParser): + _FACTORY_NAME = "SoMark" + + def __init__(self, key: str | dict, model_name: str, **kwargs): + Base.__init__(self, key, model_name, **kwargs) + raw_config: dict = {} + if isinstance(key, dict): + # API verify path passes the form dict directly; no JSON to parse. + raw_config = key + elif key: + try: + raw_config = json.loads(key) + except Exception: + raw_config = {} + + # nested {"api_key": {...}} from UI + # flat {"SOMARK_*": "..."} payload auto-provisioned from env vars + config = raw_config.get("api_key", raw_config) + if not isinstance(config, dict): + config = {} + + key_as_secret = key if isinstance(key, str) and key and not key.lstrip().startswith("{") else "" + + def _resolve(ui_key: str, env_key: str, default=""): + return config.get( + ui_key, + config.get( + env_key, + kwargs.get( + ui_key, + kwargs.get(env_key, os.environ.get(env_key, default)), + ), + ), + ) + + def _resolve_bool(ui_key: str, env_key: str, default: bool) -> bool: + raw = _resolve(ui_key, env_key, int(default)) + if isinstance(raw, bool): + return raw + if isinstance(raw, (int, float)): + return bool(raw) + return str(raw).strip().lower() in {"1", "true", "yes", "on"} + + def _resolve_int(ui_key: str, env_key: str, default: int) -> int: + raw = _resolve(ui_key, env_key, default) + try: + return int(raw) + except (TypeError, ValueError): + return default + + base_url = _resolve( + "somark_base_url", + "SOMARK_BASE_URL", + kwargs.get("base_url", "https://somark.tech/api/v1"), + ) + api_key = _resolve("somark_api_key", "SOMARK_API_KEY", key_as_secret) + image_format = _resolve("somark_image_format", "SOMARK_IMAGE_FORMAT", "url") + formula_format = _resolve("somark_formula_format", "SOMARK_FORMULA_FORMAT", "latex") + table_format = _resolve("somark_table_format", "SOMARK_TABLE_FORMAT", "html") + cs_format = _resolve("somark_cs_format", "SOMARK_CS_FORMAT", "image") + enable_text_cross_page = _resolve_bool("somark_enable_text_cross_page", "SOMARK_ENABLE_TEXT_CROSS_PAGE", False) + enable_table_cross_page = _resolve_bool("somark_enable_table_cross_page", "SOMARK_ENABLE_TABLE_CROSS_PAGE", False) + enable_title_level_recognition = _resolve_bool("somark_enable_title_level_recognition", "SOMARK_ENABLE_TITLE_LEVEL_RECOGNITION", False) + enable_inline_image = _resolve_bool("somark_enable_inline_image", "SOMARK_ENABLE_INLINE_IMAGE", True) + enable_table_image = _resolve_bool("somark_enable_table_image", "SOMARK_ENABLE_TABLE_IMAGE", True) + enable_image_understanding = _resolve_bool("somark_enable_image_understanding", "SOMARK_ENABLE_IMAGE_UNDERSTANDING", True) + keep_header_footer = _resolve_bool("somark_keep_header_footer", "SOMARK_KEEP_HEADER_FOOTER", False) + + # Redact sensitive config keys before logging + redacted_config = {} + for k, v in config.items(): + if any(s in k.lower() for s in ("key", "password", "token", "secret")): + redacted_config[k] = "[REDACTED]" + else: + redacted_config[k] = v + logging.info(f"Parsed SoMark config (sensitive fields redacted): {redacted_config}") + + SoMarkParser.__init__( + self, + base_url=base_url, + api_key=api_key, + image_format=image_format, + formula_format=formula_format, + table_format=table_format, + cs_format=cs_format, + enable_text_cross_page=enable_text_cross_page, + enable_table_cross_page=enable_table_cross_page, + enable_title_level_recognition=enable_title_level_recognition, + enable_inline_image=enable_inline_image, + enable_table_image=enable_table_image, + enable_image_understanding=enable_image_understanding, + keep_header_footer=keep_header_footer, + ) + + def check_available(self) -> tuple[bool, str]: + return self.check_installation() + + def parse_pdf(self, filepath: str, binary=None, callback=None, parse_method: str = "raw", **kwargs): + ok, reason = self.check_available() + if not ok: + raise RuntimeError(f"SoMark service not accessible: {reason}") + + # parse_method selects the output tuple shape (see SoMarkParser._transfer_to_sections): + # manual/pipeline -> typed 3-tuples for the rag/flow DAG; raw/other -> 2-tuples + # for naive.py chunking. Thread it through like MinerU rather than dropping it. + sections, tables = SoMarkParser.parse_pdf( + self, + filepath=filepath, + binary=binary, + callback=callback, + parse_method=parse_method, + **kwargs, + ) + return sections, tables diff --git a/test/unit_test/deepdoc/parser/test_somark_parser.py b/test/unit_test/deepdoc/parser/test_somark_parser.py new file mode 100644 index 0000000000..b8c722f5e7 --- /dev/null +++ b/test/unit_test/deepdoc/parser/test_somark_parser.py @@ -0,0 +1,322 @@ +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + + +def _load_somark_parser(monkeypatch): + """Load somark_parser.py directly, bypassing deepdoc/__init__.py's + beartype_this_package() and the heavy deepdoc dependency chain. + + Mirrors the pattern used by test_mineru_parser.py / test_opendataloader_parser.py. + """ + repo_root = Path(__file__).resolve().parents[4] + + deepdoc_mod = ModuleType("deepdoc") + deepdoc_mod.__path__ = [str(repo_root / "deepdoc")] + monkeypatch.setitem(sys.modules, "deepdoc", deepdoc_mod) + + parser_mod = ModuleType("deepdoc.parser") + parser_mod.__path__ = [str(repo_root / "deepdoc" / "parser")] + monkeypatch.setitem(sys.modules, "deepdoc.parser", parser_mod) + + pdf_parser_mod = ModuleType("deepdoc.parser.pdf_parser") + + class _RAGFlowPdfParser: + pass + + pdf_parser_mod.RAGFlowPdfParser = _RAGFlowPdfParser + monkeypatch.setitem(sys.modules, "deepdoc.parser.pdf_parser", pdf_parser_mod) + + utils_mod = ModuleType("deepdoc.parser.utils") + utils_mod.extract_pdf_outlines = lambda *_args, **_kwargs: [] + monkeypatch.setitem(sys.modules, "deepdoc.parser.utils", utils_mod) + + module_name = "test_somark_parser_unit_module" + module_path = repo_root / "deepdoc" / "parser" / "somark_parser.py" + spec = importlib.util.spec_from_file_location(module_name, module_path) + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, module_name, module) + spec.loader.exec_module(module) + return module + + +def _make_parser(m, **feature_kwargs): + """Build a SoMarkParser instance without triggering any network call. + __init__ only sets attributes; check_installation() is what hits the network.""" + return m.SoMarkParser( + base_url="https://example.invalid/api/v1", + api_key="", + **feature_kwargs, + ) + + +def _sample_pages(): + """A minimal pages payload mixing text, title, figure, equation, table, + toc (discarded), and an image block with no bbox (must be skipped).""" + return [ + { + "page_num": 0, + "page_size": {"w": 600, "h": 800}, + "blocks": [ + {"type": "text", "content": "hello world", "bbox": [10, 20, 100, 40]}, + {"type": "title", "content": "Chapter 1", "title_level": 1, "bbox": [10, 5, 200, 18]}, + {"type": "figure", "content": "Figure caption from understanding", "bbox": [50, 50, 300, 300]}, + {"type": "table", "content": "
a
", "bbox": [10, 400, 500, 600]}, + {"type": "equation", "content": "E=mc^2", "bbox": [10, 650, 200, 680]}, + {"type": "cate_item", "content": "should be discarded", "bbox": [0, 0, 10, 10]}, + {"type": "figure", "content": "no bbox -> skip"}, # no bbox at all + ], + } + ] + + +# --------------------------------------------------------------------- +# Type-mapping integrity (regression guard) +# --------------------------------------------------------------------- + + +def test_type_mapping_covers_every_non_discarded_block_type(monkeypatch): + """Every SoMark block type that is not in ALWAYS_DISCARDED and is not a + header/footer (which obey keep_header_footer) must have a mapping in + SOMARK_TYPE_TO_RAGFLOW. A new SoMark type added to the enum without a + mapping would silently fall back to "text"; this guard makes that + omission explicit at test time.""" + m = _load_somark_parser(monkeypatch) + header_footer = {m.SoMarkBlockType.HEADER, m.SoMarkBlockType.FOOTER} + for btype in m.SoMarkBlockType: + if btype in m.ALWAYS_DISCARDED or btype in header_footer: + continue + assert btype in m.SOMARK_TYPE_TO_RAGFLOW, f"{btype} missing from SOMARK_TYPE_TO_RAGFLOW" + + +def test_mapping_values_are_known_internal_layout_types(monkeypatch): + """Mapping values must be one of the layout types that downstream + rag/flow consumers (and chunking) understand.""" + m = _load_somark_parser(monkeypatch) + allowed = {"text", "image", "table", "code", "equation"} + for btype, internal in m.SOMARK_TYPE_TO_RAGFLOW.items(): + assert internal in allowed, f"{btype} -> {internal!r} is not a known internal type" + + +def test_always_discarded_contains_toc_and_blank(monkeypatch): + """Table-of-contents items must be discarded; if they leaked through the + knowledge base would be polluted with chapter titles repeated as chunks.""" + m = _load_somark_parser(monkeypatch) + assert m.SoMarkBlockType.CATE in m.ALWAYS_DISCARDED + assert m.SoMarkBlockType.CATE_ITEM in m.ALWAYS_DISCARDED + assert m.SoMarkBlockType.BLANK in m.ALWAYS_DISCARDED + + +# --------------------------------------------------------------------- +# _resolve_internal_type — all branches +# --------------------------------------------------------------------- + + +def test_resolve_internal_type_discards_toc_and_blank(monkeypatch): + m = _load_somark_parser(monkeypatch) + p = _make_parser(m) + assert p._resolve_internal_type(m.SoMarkBlockType.CATE) is None + assert p._resolve_internal_type(m.SoMarkBlockType.CATE_ITEM) is None + assert p._resolve_internal_type(m.SoMarkBlockType.BLANK) is None + + +def test_resolve_internal_type_header_footer_dropped_by_default(monkeypatch): + m = _load_somark_parser(monkeypatch) + p = _make_parser(m) # keep_header_footer=False (default) + assert p._resolve_internal_type(m.SoMarkBlockType.HEADER) is None + assert p._resolve_internal_type(m.SoMarkBlockType.FOOTER) is None + + +def test_resolve_internal_type_header_footer_kept_when_flagged(monkeypatch): + m = _load_somark_parser(monkeypatch) + p = _make_parser(m, keep_header_footer=True) + assert p._resolve_internal_type(m.SoMarkBlockType.HEADER) == "text" + assert p._resolve_internal_type(m.SoMarkBlockType.FOOTER) == "text" + + +def test_resolve_internal_type_unknown_falls_back_to_text(monkeypatch): + """If SoMark introduces a new block type before the mapping is updated, + we should fall back to ``text`` (silent loss is worse than a wrong layout + label).""" + m = _load_somark_parser(monkeypatch) + p = _make_parser(m) + assert p._resolve_internal_type("some_brand_new_type") == "text" + + +def test_resolve_internal_type_image_blocks(monkeypatch): + """figure/cs/qrcode/stamp must all resolve to 'image' so they share the + crop() recovery path; otherwise figures would be lost on the naive path.""" + m = _load_somark_parser(monkeypatch) + p = _make_parser(m) + for btype in (m.SoMarkBlockType.FIGURE, m.SoMarkBlockType.CS, m.SoMarkBlockType.QRCODE, m.SoMarkBlockType.STAMP): + assert p._resolve_internal_type(btype) == "image", btype + + +# --------------------------------------------------------------------- +# _block_text +# --------------------------------------------------------------------- + + +def test_block_text_image_returns_empty_string(monkeypatch): + """Image-typed blocks contribute no text via _block_text; the figure is + later recovered from the rendered page by crop().""" + m = _load_somark_parser(monkeypatch) + block = {"type": m.SoMarkBlockType.FIGURE.value, "content": "ignored"} + assert m.SoMarkParser._block_text(block, "image") == "" + + +def test_block_text_title_prepends_markdown_hashes(monkeypatch): + m = _load_somark_parser(monkeypatch) + block = {"type": m.SoMarkBlockType.TITLE.value, "content": "Hello", "title_level": 2} + assert m.SoMarkParser._block_text(block, "text") == "## Hello" + + +def test_block_text_title_without_level_returns_plain_content(monkeypatch): + m = _load_somark_parser(monkeypatch) + block = {"type": m.SoMarkBlockType.TITLE.value, "content": "Hello"} # no title_level + assert m.SoMarkParser._block_text(block, "text") == "Hello" + + +def test_block_text_text_strips_whitespace(monkeypatch): + m = _load_somark_parser(monkeypatch) + block = {"type": m.SoMarkBlockType.TEXT.value, "content": " hi "} + assert m.SoMarkParser._block_text(block, "text") == "hi" + + +# --------------------------------------------------------------------- +# _transfer_to_sections — tuple shape contract +# --------------------------------------------------------------------- + + +def test_transfer_to_sections_naive_path_returns_2_tuples(monkeypatch): + """parse_method=None (or anything not in {manual, pipeline}) — naive.py + consumer — must receive 2-tuples (text, line_tag).""" + m = _load_somark_parser(monkeypatch) + p = _make_parser(m) + + secs = p._transfer_to_sections(_sample_pages()) + + assert all(isinstance(s, tuple) and len(s) == 2 for s in secs), "naive path must emit (text, line_tag) 2-tuples" + # 7 blocks - 1 cate_item discarded - 1 no-bbox figure skipped = 5 valid + assert len(secs) == 5 + + +def test_transfer_to_sections_pipeline_path_returns_3_tuples(monkeypatch): + """parse_method='pipeline' — rag/flow consumer — must receive typed + 3-tuples (text, layout_type, line_tag), mirroring MinerU's contract.""" + m = _load_somark_parser(monkeypatch) + p = _make_parser(m) + + secs = p._transfer_to_sections(_sample_pages(), parse_method="pipeline") + + assert all(isinstance(s, tuple) and len(s) == 3 for s in secs), "pipeline path must emit (text, layout_type, line_tag) 3-tuples" + # Layout types must reflect block diversity, not collapse to all "text" + layout_types = {s[1] for s in secs} + assert layout_types >= {"text", "image", "table", "equation"}, f"expected diverse layout types, got {layout_types}" + + +def test_transfer_to_sections_naive_image_carries_caption_and_tag(monkeypatch): + """Image sections on the naive path must carry a unique caption in the + text field (to avoid chunk-id hash collision across figures) AND embed + the position tag so tokenize_chunks()->crop() can still recover the + figure. The pos field is empty by design.""" + m = _load_somark_parser(monkeypatch) + p = _make_parser(m) + + secs = p._transfer_to_sections(_sample_pages()) + image_secs = [s for s in secs if s[1] == ""] + + assert len(image_secs) == 1 + text = image_secs[0][0] + assert "Figure caption from understanding" in text, "caption must be in text" + assert "@@" in text and "##" in text, "position tag must be embedded in text" + + +def test_transfer_to_sections_pipeline_image_keeps_caption_and_typed_position(monkeypatch): + """On the pipeline path the image block keeps its caption text for semantic + retrieval and a real (separate) line_tag for crop(poss).""" + m = _load_somark_parser(monkeypatch) + p = _make_parser(m) + + secs = p._transfer_to_sections(_sample_pages(), parse_method="pipeline") + image_secs = [s for s in secs if s[1] == "image"] + + assert len(image_secs) == 1 + text, layout, line_tag = image_secs[0] + assert text == "Figure caption from understanding" + assert layout == "image" + assert line_tag.startswith("@@") and line_tag.endswith("##") + + +def test_transfer_to_sections_discards_cate_item_in_both_modes(monkeypatch): + m = _load_somark_parser(monkeypatch) + p = _make_parser(m) + for mode in (None, "pipeline"): + secs = p._transfer_to_sections(_sample_pages(), parse_method=mode) + leaked = [s for s in secs if "should be discarded" in (s[0] or "")] + assert leaked == [], f"cate_item leaked in mode={mode}: {leaked}" + + +def test_transfer_to_sections_skips_image_block_without_bbox(monkeypatch): + """No bbox means crop() can't recover anything; emitting an empty section + would only pollute the chunk stream.""" + m = _load_somark_parser(monkeypatch) + p = _make_parser(m) + pages = [ + { + "page_num": 0, + "page_size": {"w": 600, "h": 800}, + "blocks": [{"type": "figure", "content": "no bbox"}], + } + ] + assert p._transfer_to_sections(pages) == [] + + +def test_transfer_to_sections_keeps_header_footer_when_flagged(monkeypatch): + """With keep_header_footer=True, header/footer blocks should pass through + as text sections.""" + m = _load_somark_parser(monkeypatch) + p = _make_parser(m, keep_header_footer=True) + pages = [ + { + "page_num": 0, + "page_size": {"w": 600, "h": 800}, + "blocks": [ + {"type": "header", "content": "doc title", "bbox": [0, 0, 100, 10]}, + {"type": "footer", "content": "page 1", "bbox": [0, 790, 100, 800]}, + ], + } + ] + secs = p._transfer_to_sections(pages) + texts = [s[0] for s in secs] + assert any("doc title" in t for t in texts) + assert any("page 1" in t for t in texts) + + +# --------------------------------------------------------------------- +# _line_tag format +# --------------------------------------------------------------------- + + +def test_line_tag_format(monkeypatch): + """Tag format ``@@\\t\\t\\t\\t##`` is the + contract that downstream extract_positions() / crop() parse.""" + m = _load_somark_parser(monkeypatch) + p = _make_parser(m) + bx = { + "page_idx": 0, + "bbox": [10, 20, 100, 40], + "page_size": {"w": 600, "h": 800}, + } + tag = p._line_tag(bx) + + assert tag.startswith("@@1\t"), "page index must be 1-based" + assert tag.endswith("##") + parts = tag.strip("@").strip("#").split("\t") + assert len(parts) == 5, f"expected 5 tab-separated parts, got {parts}" + # Absent page_images, _line_tag uses raw bbox coords + assert float(parts[1]) == 10.0 # x0 + assert float(parts[2]) == 100.0 # x1 + assert float(parts[3]) == 20.0 # y0 (top) + assert float(parts[4]) == 40.0 # y1 (bottom) diff --git a/web/src/assets/svg/llm/somark.svg b/web/src/assets/svg/llm/somark.svg new file mode 100644 index 0000000000..8864a3cb7b --- /dev/null +++ b/web/src/assets/svg/llm/somark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/components/svg-icon.tsx b/web/src/components/svg-icon.tsx index b51cc51fac..5214c6598f 100644 --- a/web/src/components/svg-icon.tsx +++ b/web/src/components/svg-icon.tsx @@ -88,6 +88,7 @@ const svgIcons = [ // LLMFactory.DeerAPI, LLMFactory.Avian, LLMFactory.RAGcon, + LLMFactory.SoMark, LLMFactory.NewAPI, ]; diff --git a/web/src/constants/llm.ts b/web/src/constants/llm.ts index dea7096ebf..e70fe9bd8b 100644 --- a/web/src/constants/llm.ts +++ b/web/src/constants/llm.ts @@ -68,6 +68,7 @@ export enum LLMFactory { MinerU = 'MinerU', PaddleOCR = 'PaddleOCR', OpenDataLoader = 'OpenDataLoader', + SoMark = 'SoMark', N1n = 'n1n', Avian = 'Avian', RAGcon = 'RAGcon', @@ -144,6 +145,7 @@ export const IconMap = { [LLMFactory.Avian]: 'avian', [LLMFactory.RAGcon]: 'ragcon', [LLMFactory.Perplexity]: 'perplexity', + [LLMFactory.SoMark]: 'somark', [LLMFactory.NewAPI]: 'new-api', }; @@ -214,6 +216,7 @@ export const APIMapUrl = { [LLMFactory.TokenPony]: 'https://www.tokenpony.cn/#/user/keys', [LLMFactory.DeepInfra]: 'https://deepinfra.com/dash/api_keys', [LLMFactory.PaddleOCR]: 'https://www.paddleocr.ai/latest/', + [LLMFactory.SoMark]: 'https://somark.tech/workbench/apikey', [LLMFactory.N1n]: 'https://docs.n1n.ai', [LLMFactory.Avian]: 'https://avian.io', [LLMFactory.Perplexity]: diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 83cc261d0c..2a563a41b4 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -1853,6 +1853,31 @@ Example: Virtual Hosted Style`, 'Vision Language Model with LMDeploy Engine (Experimental)', }, }, + somark: { + modelNameMessage: 'Please input your model name', + baseUrl: 'Base URL', + baseUrlMessage: 'Please input the Base URL', + baseUrlPlaceholder: + 'For SoMark API, fill in https://somark.tech/api/v1; for self-hosted deployment, fill in your local Base URL', + apiKey: 'API Key', + apiKeyPlaceholder: + 'Required for SoMark API; leave blank for self-hosted deployment', + verifyPassed: 'Verified', + verifyFailed: 'Verification failed', + sectionElementFormats: 'Element Formats', + imageFormat: 'Image Format', + formulaFormat: 'Formula Format', + tableFormat: 'Table Format', + csFormat: 'Chemical Structural Formula Format', + sectionFeatureConfig: 'Feature Config', + enableInlineImage: 'Enable Inline Image', + enableTableImage: 'Enable Table Image', + enableImageUnderstanding: 'Enable Image Understanding', + enableTitleLevelRecognition: 'Enable Title Level Recognition', + enableTextCrossPage: 'Enable Text Cross Page', + enableTableCrossPage: 'Enable Table Cross Page', + keepHeaderFooter: 'Keep Header Footer', + }, modelTypes: { chat: 'Chat', embedding: 'Embedding', diff --git a/web/src/locales/zh-traditional.ts b/web/src/locales/zh-traditional.ts index 6f9e29bf52..2296283bb2 100644 --- a/web/src/locales/zh-traditional.ts +++ b/web/src/locales/zh-traditional.ts @@ -510,7 +510,8 @@ export default { thinkingDefault: '系統預設', thinkingEnabled: '開啟', thinkingDisabled: '關閉', - thinkingTip: '僅控制官方模型提供商中的 Qwen、Kimi 和 GLM 模型思考模式。系統預設會關閉 Qwen 思考,以避免任務長時間執行。', + thinkingTip: + '僅控制官方模型提供商中的 Qwen、Kimi 和 GLM 模型思考模式。系統預設會關閉 Qwen 思考,以避免任務長時間執行。', quote: '顯示引文', quoteTip: '是否應該顯示原文出處?', selfRag: 'Self-RAG', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index fe3ad9e161..8e4683e52e 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -1510,6 +1510,30 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系 vlmLmdeployEngine: '基于LMDeploy引擎的视觉语言模型(实验性)', }, }, + somark: { + modelNameMessage: '请输入模型名称', + baseUrl: 'Base URL', + baseUrlMessage: '请输入 Base URL', + baseUrlPlaceholder: + '使用 SoMark API 时填写 https://somark.tech/api/v1;私有化部署时填写本地部署的 Base URL', + apiKey: 'API Key', + apiKeyPlaceholder: '使用 SoMark API 时填写;私有化部署无需填写', + verifyPassed: '验证通过', + verifyFailed: '验证失败', + sectionElementFormats: '元素格式配置', + imageFormat: '图片格式', + formulaFormat: '公式格式', + tableFormat: '表格格式', + csFormat: '化学结构式格式', + sectionFeatureConfig: '特色功能配置', + enableInlineImage: '返回文中图', + enableTableImage: '返回表中图', + enableImageUnderstanding: '图片理解', + enableTitleLevelRecognition: '标题层级识别', + enableTextCrossPage: '文字跨页合并', + enableTableCrossPage: '表格跨页合并', + keepHeaderFooter: '保留页眉页脚', + }, paddleocr: { apiUrl: 'PaddleOCR API URL', apiUrlPlaceholder: '例如:https://paddleocr-server.com/layout-parsing', diff --git a/web/src/pages/agents/agent-dropdown.tsx b/web/src/pages/agents/agent-dropdown.tsx index e4ed10e084..4bb565dfca 100644 --- a/web/src/pages/agents/agent-dropdown.tsx +++ b/web/src/pages/agents/agent-dropdown.tsx @@ -9,10 +9,15 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { useDeleteAgent, useDuplicateAgent } from '@/hooks/use-agent-request'; +import { useDeleteAgent } from '@/hooks/use-agent-request'; import { IFlow } from '@/interfaces/database/agent'; -import { MouseEventHandler, PropsWithChildren, useCallback, useState } from 'react'; import { PenLine, Tag, Trash2 } from 'lucide-react'; +import { + MouseEventHandler, + PropsWithChildren, + useCallback, + useState, +} from 'react'; import { useTranslation } from 'react-i18next'; import { AgentTagEditor } from './agent-tag-editor'; import { useRenameAgent } from './use-rename-agent'; diff --git a/web/src/pages/user-setting/setting-model/hooks.tsx b/web/src/pages/user-setting/setting-model/hooks.tsx index 6d9d95bf2e..235f9c3936 100644 --- a/web/src/pages/user-setting/setting-model/hooks.tsx +++ b/web/src/pages/user-setting/setting-model/hooks.tsx @@ -1,3 +1,4 @@ +import { LLMFactory } from '@/constants/llm'; import { useSetModalState } from '@/hooks/common-hooks'; import { useAddInstanceModel, @@ -127,8 +128,8 @@ export const useVerifyConnection = () => { ); }; -// ============ Hooks for the 4 retained special modals ============ -// Bedrock / MinerU / PaddleOCR / OpenDataLoader are not yet merged into ProviderModal +// ============ Hooks for retained special modals ============ +// Bedrock and SoMark still use custom modal components. export const useSubmitBedrock = () => { const [saveLoading, setSaveLoading] = useState(false); @@ -185,6 +186,85 @@ export const useSubmitBedrock = () => { }; }; +export const useSubmitSoMark = () => { + const [saveLoading, setSaveLoading] = useState(false); + const submitProviderInstance = useSubmitProviderInstance(); + const { + visible: somarkVisible, + hideModal: hideSoMarkModal, + showModal: showSoMarkModal, + } = useSetModalState(); + + const onSoMarkOk = useCallback( + async (payload: any, isVerify = false) => { + if (!isVerify) { + setSaveLoading(true); + } + const req = { + instance_name: payload.instance_name, + llm_factory: LLMFactory.SoMark, + api_key: payload.somark_api_key || '', + api_base: payload.somark_base_url, + max_tokens: 0, + model_info: [ + { + model_name: payload.llm_name, + model_type: ['ocr'], + max_tokens: 0, + extra: { + somark_image_format: payload.somark_image_format, + somark_formula_format: payload.somark_formula_format, + somark_table_format: payload.somark_table_format, + somark_cs_format: payload.somark_cs_format, + somark_enable_text_cross_page: + payload.somark_enable_text_cross_page, + somark_enable_table_cross_page: + payload.somark_enable_table_cross_page, + somark_enable_title_level_recognition: + payload.somark_enable_title_level_recognition, + somark_enable_inline_image: payload.somark_enable_inline_image, + somark_enable_table_image: payload.somark_enable_table_image, + somark_enable_image_understanding: + payload.somark_enable_image_understanding, + somark_keep_header_footer: payload.somark_keep_header_footer, + }, + }, + ], + }; + try { + const ret = await submitProviderInstance( + req as IAddProviderInstanceRequestBody, + isVerify, + ); + if (isVerify) { + return { + isValid: !!ret.data?.success, + logs: ret.data?.message, + } as VerifyResult; + } + if (ret.code === 0) { + hideSoMarkModal(); + return true; + } + return false; + } finally { + if (!isVerify) { + setSaveLoading(false); + } + } + }, + [submitProviderInstance, hideSoMarkModal, setSaveLoading], + ); + + return { + somarkVisible, + hideSoMarkModal, + showSoMarkModal, + onSoMarkOk, + somarkLoading: saveLoading, + }; +}; + /** * Wraps the verify callback: provides a unified call with isVerify=true for the Verify button */ diff --git a/web/src/pages/user-setting/setting-model/index.tsx b/web/src/pages/user-setting/setting-model/index.tsx index 43eb07c6b6..e5254dc4c3 100644 --- a/web/src/pages/user-setting/setting-model/index.tsx +++ b/web/src/pages/user-setting/setting-model/index.tsx @@ -16,13 +16,14 @@ import { isLocalLlmFactory } from '../utils'; import SystemSetting from './components/system-setting'; import { AvailableModels } from './components/un-add-model'; import { UsedModel } from './components/used-model'; -import { useSubmitBedrock } from './hooks'; +import { useSubmitBedrock, useSubmitSoMark, useVerifySettings } from './hooks'; import BedrockModal from './modal/bedrock-modal'; import ProviderModal, { IViewModeOkPayload } from './modal/provider-modal'; +import SoMarkModal from './modal/somark-modal'; import { splitProviderPayload } from './payload-utils'; const ModelProviders = () => { - // 4 retained special modals + // Retained special modals const { bedrockAddingLoading, onBedrockAddingOk, @@ -170,6 +171,14 @@ const ModelProviders = () => { [verifyProviderConnection, currentLlmFactory], ); + const { + somarkVisible, + hideSoMarkModal, + showSoMarkModal, + onSoMarkOk, + somarkLoading, + } = useSubmitSoMark(); + const ModalMap = useMemo( () => ({ [LLMFactory.Bedrock]: showBedrockAddingModal, @@ -213,8 +222,9 @@ const ModelProviders = () => { setCurrentLlmFactory(LLMFactory.OpenDataLoader); setProviderVisible(true); }, + [LLMFactory.SoMark]: showSoMarkModal, }), - [showBedrockAddingModal], + [showBedrockAddingModal, showSoMarkModal], ); const handleAddModel = useCallback( @@ -347,6 +357,10 @@ const ModelProviders = () => { setViewMode(false); }, []); + const { onApiKeyVerifying: onSoMarkVerifying } = useVerifySettings({ + onVerify: onSoMarkOk, + }); + return (
@@ -381,6 +395,13 @@ const ModelProviders = () => { llmFactory={LLMFactory.Bedrock} onVerify={(payload) => onBedrockAddingOk(payload, true)} > +
); }; diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-picker.ts b/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-picker.ts index d68aa3acb7..934cc9d054 100644 --- a/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-picker.ts +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-picker.ts @@ -161,9 +161,10 @@ export const useListModelsPicker = ({ // In viewMode, pass instance_name so the backend looks up // stored credentials server-side (avoids exposing api_key). // For new-instance mode, pass api_key/base_url from the form. - const values = viewMode && initialValues - ? { ...initialValues, ...rawValues } - : rawValues; + const values = + viewMode && initialValues + ? { ...initialValues, ...rawValues } + : rawValues; const verifyArgs = config.verifyTransform ? config.verifyTransform({ ...values, @@ -294,8 +295,9 @@ export const useListModelsPicker = ({ ); // --- Model editing --- - const [editingModel, setEditingModel] = - useState(null); + const [editingModel, setEditingModel] = useState( + null, + ); const [editDialogOpen, setEditDialogOpen] = useState(false); const handleEditModel = useCallback((model: IProviderModelItem) => { diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/index.tsx b/web/src/pages/user-setting/setting-model/modal/provider-modal/index.tsx index a6d15a18c5..78af33a257 100644 --- a/web/src/pages/user-setting/setting-model/modal/provider-modal/index.tsx +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/index.tsx @@ -13,8 +13,8 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FieldValues } from 'react-hook-form'; import { LLMHeader } from '../../components/llm-header'; import VerifyButton from '../verify-button'; -import { AddableToggleList } from './components/addable-toggle-list'; import { AddCustomModelDialog } from './components/add-custom-model-dialog'; +import { AddableToggleList } from './components/addable-toggle-list'; import { useCustomModelFields } from './components/use-custom-model-fields'; import { useListModelsOptions, @@ -288,19 +288,17 @@ const ProviderModal = ({ f.name === 'name' ? editingModel.name : f.name === 'model_types' - ? editingModel.model_types ?? [] + ? (editingModel.model_types ?? []) : f.name === 'max_tokens' - ? editingModel.max_tokens ?? 0 + ? (editingModel.max_tokens ?? 0) : f.name === 'features' - ? editingModel.features ?? [] + ? (editingModel.features ?? []) : f.defaultValue, }))} onSubmit={handleSaveEditedModel} submitText={tc('ok')} cancelText={tc('cancel')} - existingNames={existingNames.filter( - (n) => n !== editingModel.name, - )} + existingNames={existingNames.filter((n) => n !== editingModel.name)} /> )} diff --git a/web/src/pages/user-setting/setting-model/modal/somark-modal/index.tsx b/web/src/pages/user-setting/setting-model/modal/somark-modal/index.tsx new file mode 100644 index 0000000000..871a8b49c9 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/modal/somark-modal/index.tsx @@ -0,0 +1,366 @@ +import { RAGFlowFormItem } from '@/components/ragflow-form'; +import { Button, ButtonLoading } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Form } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { RAGFlowSelect } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { LLMFactory } from '@/constants/llm'; +import { VerifyResult } from '@/pages/user-setting/setting-model/hooks'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { memo, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; +import { LLMHeader } from '../../components/llm-header'; +import VerifyButton from '../verify-button'; + +const IMAGE_FORMATS = ['url', 'base64', 'none'] as const; +const FORMULA_FORMATS = ['latex', 'mathml', 'ascii'] as const; +const TABLE_FORMATS = ['html', 'markdown', 'image'] as const; +const CS_FORMATS = ['image'] as const; +const FORMAT_LABELS = { + url: 'URL', + base64: 'Base64', + none: 'None', + latex: 'LaTeX', + mathml: 'MathML', + ascii: 'ASCII', + html: 'HTML', + markdown: 'Markdown', + image: 'Image', +} as const; + +export type SoMarkFormValues = { + instance_name: string; + llm_name: string; + somark_base_url: string; + somark_api_key?: string; + somark_image_format: (typeof IMAGE_FORMATS)[number]; + somark_formula_format: (typeof FORMULA_FORMATS)[number]; + somark_table_format: (typeof TABLE_FORMATS)[number]; + somark_cs_format: (typeof CS_FORMATS)[number]; + somark_enable_text_cross_page: boolean; + somark_enable_table_cross_page: boolean; + somark_enable_title_level_recognition: boolean; + somark_enable_inline_image: boolean; + somark_enable_table_image: boolean; + somark_enable_image_understanding: boolean; + somark_keep_header_footer: boolean; +}; + +export interface IModalProps { + visible: boolean; + hideModal: () => void; + onOk?: (data: T) => Promise; + onVerify?: ( + postBody: any, + ) => Promise; + loading?: boolean; +} + +const SectionTitle = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +const buildFormatOptions = ( + formats: readonly T[], +) => formats.map((value) => ({ label: FORMAT_LABELS[value], value })); + +const SoMarkModal = ({ + visible, + hideModal, + onOk, + onVerify, + loading, +}: IModalProps) => { + const { t } = useTranslation(); + + const FormSchema = useMemo( + () => + z.object({ + instance_name: z.string().min(1, { + message: t('setting.instanceNameMessage'), + }), + llm_name: z.string().min(1, { + message: t('setting.somark.modelNameMessage'), + }), + somark_base_url: z.string().min(1, { + message: t('setting.somark.baseUrlMessage'), + }), + somark_api_key: z.string().optional(), + somark_image_format: z.enum(IMAGE_FORMATS), + somark_formula_format: z.enum(FORMULA_FORMATS), + somark_table_format: z.enum(TABLE_FORMATS), + somark_cs_format: z.enum(CS_FORMATS), + somark_enable_text_cross_page: z.boolean(), + somark_enable_table_cross_page: z.boolean(), + somark_enable_title_level_recognition: z.boolean(), + somark_enable_inline_image: z.boolean(), + somark_enable_table_image: z.boolean(), + somark_enable_image_understanding: z.boolean(), + somark_keep_header_footer: z.boolean(), + }), + [t], + ); + + const imageFormatOptions = useMemo( + () => buildFormatOptions(IMAGE_FORMATS), + [], + ); + const formulaFormatOptions = useMemo( + () => buildFormatOptions(FORMULA_FORMATS), + [], + ); + const tableFormatOptions = useMemo( + () => buildFormatOptions(TABLE_FORMATS), + [], + ); + const csFormatOptions = useMemo(() => buildFormatOptions(CS_FORMATS), []); + + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + instance_name: '', + llm_name: '', + somark_base_url: '', + somark_api_key: '', + somark_image_format: 'url', + somark_formula_format: 'latex', + somark_table_format: 'html', + somark_cs_format: 'image', + somark_enable_text_cross_page: false, + somark_enable_table_cross_page: false, + somark_enable_title_level_recognition: false, + somark_enable_inline_image: false, + somark_enable_table_image: true, + somark_enable_image_understanding: true, + somark_keep_header_footer: false, + }, + }); + + const handleOk = async (values: SoMarkFormValues) => { + const ret = await onOk?.(values as any); + if (ret) { + hideModal?.(); + } + }; + + return ( + + + + + + + +
+ + + + + + + + + + + + + + + + {t('setting.somark.sectionElementFormats')} + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + + {t('setting.somark.sectionFeatureConfig')} + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + {onVerify && ( + Promise} + isAbsolute={false} + validLabel={t('setting.somark.verifyPassed')} + invalidLabel={t('setting.somark.verifyFailed')} + /> + )} + + + + + + {t('common.add')} + + +
+
+ ); +}; + +export default memo(SoMarkModal); diff --git a/web/src/pages/user-setting/setting-model/modal/verify-button/index.tsx b/web/src/pages/user-setting/setting-model/modal/verify-button/index.tsx index a2861a8ee6..039bfba059 100644 --- a/web/src/pages/user-setting/setting-model/modal/verify-button/index.tsx +++ b/web/src/pages/user-setting/setting-model/modal/verify-button/index.tsx @@ -13,6 +13,10 @@ interface IVerifyButton { isAbsolute?: boolean; params?: any; className?: string; + /** Override the success label shown next to the button. Defaults to t('keyValid'). */ + validLabel?: string; + /** Override the failure label shown next to the button. Defaults to t('keyInvalid'). */ + invalidLabel?: string; verifyCallback?: (result: VerifyResult | null) => void; } @@ -21,6 +25,8 @@ const VerifyButton: React.FC = ({ isAbsolute = true, params, className, + validLabel, + invalidLabel, verifyCallback, }) => { const { t, i18n } = useTranslate('setting'); @@ -117,7 +123,9 @@ const VerifyButton: React.FC = ({ }`} > - {verifyResult.isValid ? t('keyValid') : t('keyInvalid')} + {verifyResult.isValid + ? (validLabel ?? t('keyValid')) + : (invalidLabel ?? t('keyInvalid'))} )} diff --git a/web/src/pages/user-setting/setting-model/payload-utils.ts b/web/src/pages/user-setting/setting-model/payload-utils.ts index 052aef007d..f9df605776 100644 --- a/web/src/pages/user-setting/setting-model/payload-utils.ts +++ b/web/src/pages/user-setting/setting-model/payload-utils.ts @@ -26,6 +26,17 @@ export const MODEL_EXTRA_KEYS = new Set([ 'vision', 'provider_order', 'api_version', + 'somark_image_format', + 'somark_formula_format', + 'somark_table_format', + 'somark_cs_format', + 'somark_enable_text_cross_page', + 'somark_enable_table_cross_page', + 'somark_enable_title_level_recognition', + 'somark_enable_inline_image', + 'somark_enable_table_image', + 'somark_enable_image_understanding', + 'somark_keep_header_footer', ]); export const MODEL_FIELD_NAMES = new Set([