Feat: SoMark (#16482)

Follow #15486
Co-authored-by: limuting <limuting233@gmail.com>
Co-authored-by: lutianyi <lutianyi233@163.com>
Co-authored-by: justinychuang <huangyicheng@soulcode.cn>
Co-authored-by: maybehokori <138367708+maybehokori@users.noreply.github.com>
This commit is contained in:
Lynn
2026-07-01 13:29:28 +08:00
committed by GitHub
parent b6fa5ce4ea
commit 400476f0b3
24 changed files with 2071 additions and 154 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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,
}

View File

@@ -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 ``<llm_name>@<instance_name>@<provider>``
# 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

View File

@@ -7488,6 +7488,14 @@
"status": "1",
"llm": []
},
{
"name": "SoMark",
"logo": "",
"tags": "OCR",
"status": "1",
"rank": "930",
"llm": []
},
{
"name": "n1n",
"logo": "",

View File

@@ -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 "<type> <seq>") 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)

View File

@@ -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

View File

@@ -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 ``<llm_name>@<instance_name>@<provider>``
# 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,

View File

@@ -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

View File

@@ -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": "<table><tr><td>a</td></tr></table>", "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 ``@@<page1based>\\t<x0>\\t<x1>\\t<y0>\\t<y1>##`` 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)

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -88,6 +88,7 @@ const svgIcons = [
// LLMFactory.DeerAPI,
LLMFactory.Avian,
LLMFactory.RAGcon,
LLMFactory.SoMark,
LLMFactory.NewAPI,
];

View File

@@ -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]:

View File

@@ -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',

View File

@@ -510,7 +510,8 @@ export default {
thinkingDefault: '系統預設',
thinkingEnabled: '開啟',
thinkingDisabled: '關閉',
thinkingTip: '僅控制官方模型提供商中的 Qwen、Kimi 和 GLM 模型思考模式。系統預設會關閉 Qwen 思考,以避免任務長時間執行。',
thinkingTip:
'僅控制官方模型提供商中的 Qwen、Kimi 和 GLM 模型思考模式。系統預設會關閉 Qwen 思考,以避免任務長時間執行。',
quote: '顯示引文',
quoteTip: '是否應該顯示原文出處?',
selfRag: 'Self-RAG',

View File

@@ -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',

View File

@@ -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';

View File

@@ -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
*/

View File

@@ -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 (
<div className="flex w-full border-[0.5px] border-border-button rounded-lg relative ">
<Spotlight />
@@ -381,6 +395,13 @@ const ModelProviders = () => {
llmFactory={LLMFactory.Bedrock}
onVerify={(payload) => onBedrockAddingOk(payload, true)}
></BedrockModal>
<SoMarkModal
visible={somarkVisible}
hideModal={hideSoMarkModal}
onOk={onSoMarkOk}
loading={somarkLoading}
onVerify={onSoMarkVerifying}
></SoMarkModal>
</div>
);
};

View File

@@ -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<IProviderModelItem | null>(null);
const [editingModel, setEditingModel] = useState<IProviderModelItem | null>(
null,
);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const handleEditModel = useCallback((model: IProviderModelItem) => {

View File

@@ -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)}
/>
)}
</Modal>

View File

@@ -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<T> {
visible: boolean;
hideModal: () => void;
onOk?: (data: T) => Promise<boolean>;
onVerify?: (
postBody: any,
) => Promise<boolean | void | VerifyResult | undefined>;
loading?: boolean;
}
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
<div className="text-sm font-semibold text-muted-foreground border-b pb-1">
{children}
</div>
);
const buildFormatOptions = <T extends keyof typeof FORMAT_LABELS>(
formats: readonly T[],
) => formats.map((value) => ({ label: FORMAT_LABELS[value], value }));
const SoMarkModal = ({
visible,
hideModal,
onOk,
onVerify,
loading,
}: IModalProps<SoMarkFormValues>) => {
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<SoMarkFormValues>({
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 (
<Dialog open={visible} onOpenChange={hideModal}>
<DialogContent className="max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
<LLMHeader name={LLMFactory.SoMark} />
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleOk)}
className="space-y-5"
id="somark-form"
>
<RAGFlowFormItem
name="instance_name"
label={t('setting.instanceName')}
tooltip={t('setting.instanceNameTip')}
required
>
<Input placeholder={t('setting.instanceNameMessage')} />
</RAGFlowFormItem>
<RAGFlowFormItem
name="llm_name"
label={t('setting.modelName')}
required
>
<Input placeholder="somark-from-env-1" />
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_base_url"
label={t('setting.somark.baseUrl')}
required
>
<Input placeholder={t('setting.somark.baseUrlPlaceholder')} />
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_api_key"
label={t('setting.somark.apiKey')}
>
<Input
type="password"
placeholder={t('setting.somark.apiKeyPlaceholder')}
/>
</RAGFlowFormItem>
<SectionTitle>
{t('setting.somark.sectionElementFormats')}
</SectionTitle>
<RAGFlowFormItem
name="somark_image_format"
label={t('setting.somark.imageFormat')}
>
{(field) => (
<RAGFlowSelect
value={field.value}
onChange={field.onChange}
options={imageFormatOptions}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_formula_format"
label={t('setting.somark.formulaFormat')}
>
{(field) => (
<RAGFlowSelect
value={field.value}
onChange={field.onChange}
options={formulaFormatOptions}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_table_format"
label={t('setting.somark.tableFormat')}
>
{(field) => (
<RAGFlowSelect
value={field.value}
onChange={field.onChange}
options={tableFormatOptions}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_cs_format"
label={t('setting.somark.csFormat')}
>
{(field) => (
<RAGFlowSelect
value={field.value}
onChange={field.onChange}
options={csFormatOptions}
/>
)}
</RAGFlowFormItem>
<SectionTitle>
{t('setting.somark.sectionFeatureConfig')}
</SectionTitle>
<RAGFlowFormItem
name="somark_enable_text_cross_page"
label={t('setting.somark.enableTextCrossPage')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_enable_table_cross_page"
label={t('setting.somark.enableTableCrossPage')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_enable_title_level_recognition"
label={t('setting.somark.enableTitleLevelRecognition')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_enable_inline_image"
label={t('setting.somark.enableInlineImage')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_enable_table_image"
label={t('setting.somark.enableTableImage')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_enable_image_understanding"
label={t('setting.somark.enableImageUnderstanding')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="somark_keep_header_footer"
label={t('setting.somark.keepHeaderFooter')}
labelClassName="!mb-0"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
</RAGFlowFormItem>
{onVerify && (
<VerifyButton
onVerify={onVerify as (postBody: any) => Promise<VerifyResult>}
isAbsolute={false}
validLabel={t('setting.somark.verifyPassed')}
invalidLabel={t('setting.somark.verifyFailed')}
/>
)}
</form>
</Form>
<DialogFooter className="flex justify-end space-x-2">
<Button type="button" variant="secondary" onClick={hideModal}>
{t('common.cancel')}
</Button>
<ButtonLoading type="submit" form="somark-form" loading={loading}>
{t('common.add')}
</ButtonLoading>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default memo(SoMarkModal);

View File

@@ -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<IVerifyButton> = ({
isAbsolute = true,
params,
className,
validLabel,
invalidLabel,
verifyCallback,
}) => {
const { t, i18n } = useTranslate('setting');
@@ -117,7 +123,9 @@ const VerifyButton: React.FC<IVerifyButton> = ({
}`}
>
<span>
{verifyResult.isValid ? t('keyValid') : t('keyInvalid')}
{verifyResult.isValid
? (validLabel ?? t('keyValid'))
: (invalidLabel ?? t('keyInvalid'))}
</span>
</div>
)}

View File

@@ -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<string>([