mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-11 14:15:40 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7488,6 +7488,14 @@
|
||||
"status": "1",
|
||||
"llm": []
|
||||
},
|
||||
{
|
||||
"name": "SoMark",
|
||||
"logo": "",
|
||||
"tags": "OCR",
|
||||
"status": "1",
|
||||
"rank": "930",
|
||||
"llm": []
|
||||
},
|
||||
{
|
||||
"name": "n1n",
|
||||
"logo": "",
|
||||
|
||||
756
deepdoc/parser/somark_parser.py
Normal file
756
deepdoc/parser/somark_parser.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
322
test/unit_test/deepdoc/parser/test_somark_parser.py
Normal file
322
test/unit_test/deepdoc/parser/test_somark_parser.py
Normal 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)
|
||||
1
web/src/assets/svg/llm/somark.svg
Normal file
1
web/src/assets/svg/llm/somark.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.1 KiB |
@@ -88,6 +88,7 @@ const svgIcons = [
|
||||
// LLMFactory.DeerAPI,
|
||||
LLMFactory.Avian,
|
||||
LLMFactory.RAGcon,
|
||||
LLMFactory.SoMark,
|
||||
LLMFactory.NewAPI,
|
||||
];
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -510,7 +510,8 @@ export default {
|
||||
thinkingDefault: '系統預設',
|
||||
thinkingEnabled: '開啟',
|
||||
thinkingDisabled: '關閉',
|
||||
thinkingTip: '僅控制官方模型提供商中的 Qwen、Kimi 和 GLM 模型思考模式。系統預設會關閉 Qwen 思考,以避免任務長時間執行。',
|
||||
thinkingTip:
|
||||
'僅控制官方模型提供商中的 Qwen、Kimi 和 GLM 模型思考模式。系統預設會關閉 Qwen 思考,以避免任務長時間執行。',
|
||||
quote: '顯示引文',
|
||||
quoteTip: '是否應該顯示原文出處?',
|
||||
selfRag: 'Self-RAG',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>([
|
||||
|
||||
Reference in New Issue
Block a user