This commit is contained in:
2026-03-24 18:07:22 +08:00
parent e062368ef2
commit 9a16f738d8
121 changed files with 8904 additions and 3940 deletions
+13
View File
@@ -0,0 +1,13 @@
"""核心服务模块"""
from .llm_factory import create_chat_model
from .prompt_manager import get_prompt_manager
from .sql_prompt_manager import get_sql_prompt_manager
from .template_matcher import get_template_matcher
__all__ = [
"create_chat_model",
"get_prompt_manager",
"get_sql_prompt_manager",
"get_template_matcher",
]
+16
View File
@@ -0,0 +1,16 @@
from typing import Optional
from langchain_openai import ChatOpenAI
from config import Config, MAX_RETRIES, TIMEOUT
def create_chat_model(model_section: Optional[str] = None) -> ChatOpenAI:
"""创建 LLM 实例"""
model_config = Config.get_model_config(model_section)
return ChatOpenAI(
model=model_config['model'],
api_key=model_config['api_key'],
base_url=model_config.get('base_url'),
temperature=0.1,
max_retries=MAX_RETRIES,
timeout=TIMEOUT
)
+42
View File
@@ -0,0 +1,42 @@
import os
from typing import Any, Dict, Optional
import yaml
class PromptManager:
"""提示词配置管理器"""
def __init__(self, config_path: Optional[str] = None):
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
self._config_path = config_path or os.path.join(root_dir, "config", "prompts.yaml")
self._data: Dict[str, Any] = {}
self.reload()
def reload(self) -> None:
"""重新加载提示词配置"""
with open(self._config_path, "r", encoding="utf-8") as f:
self._data = yaml.safe_load(f) or {}
def get(self, group: str, name: str, default: str = "") -> str:
"""获取指定提示词"""
return str(self._data.get(group, {}).get(name, default))
def list_groups(self) -> list[str]:
"""列出所有分组"""
return list(self._data.keys())
def list_prompts(self, group: str) -> list[str]:
"""列出分组内提示词"""
return list(self._data.get(group, {}).keys())
_GLOBAL_PROMPT_MANAGER: Optional[PromptManager] = None
def get_prompt_manager(config_path: Optional[str] = None) -> PromptManager:
"""获取全局 PromptManager(单例)"""
global _GLOBAL_PROMPT_MANAGER
if _GLOBAL_PROMPT_MANAGER is None:
_GLOBAL_PROMPT_MANAGER = PromptManager(config_path=config_path)
return _GLOBAL_PROMPT_MANAGER
+238
View File
@@ -0,0 +1,238 @@
import json
import os
from typing import Any, Dict, List, Optional
from config import Config
from services.storage.cache import CacheBase, NoopCache, RedisCache
class SqlPromptManager:
"""按表名读取 SQL 提示词,Redis 主存储 + 本地文件回退"""
KEY_PREFIX = "sql_prompt"
TABLE_LIST_KEY = "sql_prompt:table_list"
SOURCE_REDIS = "redis"
SOURCE_FILE = "file"
def __init__(self, base_dir: Optional[str] = None):
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
self._fallback_dir = base_dir or os.path.join(root_dir, "config", "sql_gen_prompts")
self._cache = self._init_cache()
self._cache_ttl = self._get_cache_ttl()
self._use_redis_primary = self._get_use_redis_primary()
@staticmethod
def _first_line(value: Any) -> str:
if value is None:
return ""
text = str(value)
return text.splitlines()[0].strip() if text else ""
@classmethod
def _to_bool(cls, value: Any, default: bool = False) -> bool:
text = cls._first_line(value).lower()
if not text:
return default
return text in ("1", "true", "yes", "y", "on")
@classmethod
def _to_int(cls, value: Any, default: int = 0) -> int:
text = cls._first_line(value)
if not text:
return default
try:
return int(text)
except Exception:
return default
@classmethod
def _extract_key_from_multiline_values(cls, redis_cfg: Dict[str, Any], target_key: str) -> str:
token = f"{target_key}="
for raw in redis_cfg.values():
text = str(raw or "")
for line in text.splitlines()[1:]:
cleaned = line.strip()
normalized = cleaned.replace(" ", "")
if normalized.lower().startswith(token.lower()):
return cleaned.split("=", 1)[1].strip()
return ""
@classmethod
def _get_cfg_value(cls, redis_cfg: Dict[str, Any], key: str, default: Any = "") -> Any:
if key in redis_cfg:
return redis_cfg.get(key, default)
recovered = cls._extract_key_from_multiline_values(redis_cfg, key)
return recovered if recovered else default
@classmethod
def _get_cache_ttl(cls) -> Optional[int]:
redis_cfg = Config.get_section("redis")
ttl = cls._to_int(cls._get_cfg_value(redis_cfg, "sql_prompt_ttl", 0), default=0)
if ttl:
return ttl
return None
@classmethod
def _get_use_redis_primary(cls) -> bool:
redis_cfg = Config.get_section("redis")
enabled = cls._to_bool(cls._get_cfg_value(redis_cfg, "enabled", "false"), default=False)
primary = cls._to_bool(cls._get_cfg_value(redis_cfg, "sql_prompt_redis_primary", "false"), default=False)
return enabled and primary
@classmethod
def _init_cache(cls):
redis_cfg = Config.get_section("redis")
enabled = cls._to_bool(cls._get_cfg_value(redis_cfg, "enabled", "false"), default=False)
if not enabled:
return NoopCache()
url = cls._first_line(cls._get_cfg_value(redis_cfg, "url"))
db = cls._to_int(cls._get_cfg_value(redis_cfg, "db", cls._get_cfg_value(redis_cfg, "database", 0)), default=0)
if not url:
host = cls._first_line(cls._get_cfg_value(redis_cfg, "host"))
port = cls._first_line(cls._get_cfg_value(redis_cfg, "port", "6379")) or "6379"
password = cls._first_line(cls._get_cfg_value(redis_cfg, "password", ""))
username = cls._first_line(cls._get_cfg_value(redis_cfg, "username", ""))
database = cls._first_line(cls._get_cfg_value(redis_cfg, "database", str(db))) or str(db)
if host:
from urllib.parse import quote_plus
if username and password:
auth = f"{quote_plus(username)}:{quote_plus(password)}@"
elif password:
auth = f":{quote_plus(password)}@"
else:
auth = ""
url = f"redis://{auth}{host}:{port}/{database}"
if not url:
return NoopCache()
try:
return RedisCache(url=url, db=db)
except Exception:
return NoopCache()
@staticmethod
def _safe_filename(name: str) -> str:
return name.replace("..", "").replace("/", "_").replace("\\", "_")
def _redis_key(self, table_name: str) -> str:
return f"{self.KEY_PREFIX}:{table_name}"
def get_prompt(self, table_name: str) -> Optional[Dict[str, Any]]:
"""读取指定表的提示词,优先 Redis,回退本地文件"""
if not table_name:
return None
if self._use_redis_primary:
prompt = self._get_from_redis(table_name)
if prompt:
return prompt
prompt = self._get_from_file(table_name)
return prompt
def get_prompt_with_source(self, table_name: str) -> tuple[Optional[Dict[str, Any]], str]:
"""读取指定表的提示词,返回 (prompt, source) 元组"""
if not table_name:
return None, self.SOURCE_FILE
if self._use_redis_primary:
prompt = self._get_from_redis(table_name)
if prompt:
return prompt, self.SOURCE_REDIS
prompt = self._get_from_file(table_name)
source = self.SOURCE_FILE if prompt else self.SOURCE_FILE
return prompt, source
def _get_from_redis(self, table_name: str) -> Optional[Dict[str, Any]]:
key = self._redis_key(table_name)
try:
data = self._cache.get(key)
if data:
try:
return json.loads(data)
except Exception:
pass
except Exception:
pass
return None
def _get_from_file(self, table_name: str) -> Optional[Dict[str, Any]]:
safe_name = self._safe_filename(table_name)
filename = safe_name + ".json"
path = os.path.join(self._fallback_dir, filename)
if not os.path.exists(path):
return None
with open(path, "r", encoding="utf-8") as f:
prompt = json.load(f)
return prompt
def save_prompt(self, table_name: str, prompt: Dict[str, Any]) -> bool:
"""保存提示词到 Redis"""
if not table_name or not prompt:
return False
key = self._redis_key(table_name)
try:
self._cache.set(key, json.dumps(prompt, ensure_ascii=False), self._cache_ttl)
return True
except Exception:
return False
def delete_prompt(self, table_name: str) -> bool:
"""从 Redis 删除提示词"""
if not table_name:
return False
key = self._redis_key(table_name)
try:
self._cache.delete(key)
return True
except Exception:
return False
def list_tables(self) -> List[str]:
"""列出 Redis 中所有表名"""
pattern = f"{self.KEY_PREFIX}:*"
keys = self._cache.keys(pattern)
tables = []
for key in keys:
if key == self.TABLE_LIST_KEY:
continue
parts = key.split(":", 1)
if len(parts) == 2:
tables.append(parts[1])
return tables
def sync_from_files(self, tables: Optional[List[str]] = None) -> Dict[str, bool]:
"""从本地文件同步到 Redis"""
results: Dict[str, bool] = {}
if tables:
files_to_sync = [f"{self._safe_filename(t)}.json" for t in tables]
else:
try:
files_to_sync = [f for f in os.listdir(self._fallback_dir) if f.endswith(".json")]
except Exception:
return results
for filename in files_to_sync:
table_name = filename[:-5]
prompt = self._get_from_file(table_name)
if prompt:
results[table_name] = self.save_prompt(table_name, prompt)
return results
_GLOBAL_SQL_PROMPT_MANAGER: Optional[SqlPromptManager] = None
def get_sql_prompt_manager(base_dir: Optional[str] = None) -> SqlPromptManager:
"""获取全局 SqlPromptManager(单例)"""
global _GLOBAL_SQL_PROMPT_MANAGER
if _GLOBAL_SQL_PROMPT_MANAGER is None:
_GLOBAL_SQL_PROMPT_MANAGER = SqlPromptManager(base_dir=base_dir)
return _GLOBAL_SQL_PROMPT_MANAGER
+201
View File
@@ -0,0 +1,201 @@
import json
import re
from pathlib import Path
from typing import Any, Dict, Optional, Set
from config import Config
from services.core.sql_prompt_manager import get_sql_prompt_manager
from services.integrations.ragflow_client import RagflowClient, extract_table_name
IDENTIFIER_RE = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*")
EXPLICIT_FILTER_FIELD_RE = re.compile(r"([a-zA-Z_][a-zA-Z0-9_]*)\s*=")
def _normalize_term(term: str) -> str:
return str(term or "").strip().lower()
class TemplateMatcher:
"""模板匹配器:RAGFlow 检索"""
KEYWORD_MATCH_BONUS = 50
def __init__(self):
self._ragflow = RagflowClient()
self._sql_prompt_manager = get_sql_prompt_manager()
cfg = Config.get_section("ragflow")
self._dataset_id = (cfg.get("table_retrieval_dataset_id") or "").strip()
self._top_k = int(cfg.get("retrieval_top_k", 3))
self._non_empty_tables, self._table_keywords = self._load_table_config()
self._table_terms_cache: Dict[str, Set[str]] = {}
def _load_table_config(self) -> tuple[Optional[Set[str]], Dict[str, Set[str]]]:
"""从本地 tables.json 读取非空模板表集合和关键词映射。"""
try:
tables_path = Path(__file__).resolve().parents[2] / "config" / "table_retrieval_prompts" / "tables.json"
with open(tables_path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
return None, {}
non_empty: Set[str] = set()
keywords_map: Dict[str, Set[str]] = {}
for table_name, templates in data.items():
if not isinstance(table_name, str):
continue
if isinstance(templates, list) and len(templates) > 0:
non_empty.add(table_name)
keywords_map[table_name] = {_normalize_term(kw) for kw in templates if isinstance(kw, str)}
return non_empty, keywords_map
except Exception:
return None, {}
def _validate(self) -> None:
if not self._dataset_id:
raise RuntimeError("未配置 ragflow.table_retrieval_dataset_id,无法进行表名检索")
@staticmethod
def _extract_query_terms(normalized_text: str) -> Set[str]:
return {
_normalize_term(match.group(0))
for match in IDENTIFIER_RE.finditer(normalized_text or "")
}
@staticmethod
def _extract_explicit_filter_fields(normalized_text: str) -> Set[str]:
return {
_normalize_term(match.group(1))
for match in EXPLICIT_FILTER_FIELD_RE.finditer(normalized_text or "")
}
def _load_table_terms(self, table_name: str) -> Set[str]:
cached = self._table_terms_cache.get(table_name)
if cached is not None:
return cached
prompt = self._sql_prompt_manager.get_prompt(table_name) or {}
terms: Set[str] = set()
for field in (prompt.get("data_model_specification") or {}).get("fields_list") or []:
if isinstance(field, str):
terms.add(_normalize_term(field))
field_ref = prompt.get("field_mapping_reference") or {}
self._collect_mapping_terms(field_ref, terms)
self._table_terms_cache[table_name] = terms
return terms
def _collect_mapping_terms(self, node: Any, terms: Set[str]) -> None:
if isinstance(node, dict):
for key, value in node.items():
if key == "alias" and isinstance(value, list):
for alias in value:
if isinstance(alias, str):
terms.add(_normalize_term(alias))
continue
if isinstance(value, dict):
if "alias" in value or "type" in value:
terms.add(_normalize_term(key))
self._collect_mapping_terms(value, terms)
elif isinstance(value, list):
# 对字段列表直接入词,增强字段覆盖匹配
if key.endswith("_fields") or key in {"fields_list", "list"}:
for item in value:
if isinstance(item, str):
terms.add(_normalize_term(item))
self._collect_mapping_terms(value, terms)
elif isinstance(node, list):
for item in node:
self._collect_mapping_terms(item, terms)
def _rank_candidates(self, normalized_text: str, candidates: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
query_terms = self._extract_query_terms(normalized_text)
explicit_fields = self._extract_explicit_filter_fields(normalized_text)
if not query_terms or not candidates:
return candidates
ranked: list[Dict[str, Any]] = []
for index, candidate in enumerate(candidates):
table_name = candidate.get("table_name")
if not table_name:
continue
table_terms = self._load_table_terms(table_name)
overlap = len(query_terms & table_terms)
missing_explicit_fields = len([field for field in explicit_fields if field not in table_terms])
keyword_bonus = 0
table_keywords = self._table_keywords.get(table_name, set())
if table_keywords:
matched_keywords = query_terms & table_keywords
keyword_bonus = len(matched_keywords) * self.KEYWORD_MATCH_BONUS
rank_score = overlap + keyword_bonus - (missing_explicit_fields * 5)
ranked.append({
**candidate,
"rank_score": rank_score,
"rank_overlap": overlap,
"rank_keyword_bonus": keyword_bonus,
"rank_missing_explicit_fields": missing_explicit_fields,
"rank_index": index,
})
ranked.sort(key=lambda item: (item["rank_score"], item["rank_overlap"], -item["rank_index"]), reverse=True)
return ranked
def match(self, normalized_text: str) -> Dict[str, Any]:
"""返回匹配的表名与原始响应"""
self._validate()
try:
response = self._ragflow.retrieve(normalized_text, top_k=self._top_k, dataset_id=self._dataset_id)
except Exception as e:
return {"table_name": None, "candidates": [], "raw": {"error": str(e)}}
candidates = []
seen = set()
data = response.get("data") if isinstance(response, dict) else None
records = []
if isinstance(data, list):
records = data
elif isinstance(data, dict):
chunks = data.get("chunks")
if isinstance(chunks, list):
records = chunks
for item in records:
table_name = extract_table_name(item)
if self._non_empty_tables is not None and table_name not in self._non_empty_tables:
continue
if table_name and table_name not in seen:
seen.add(table_name)
candidates.append(
{
"table_name": table_name,
"metadata": item.get("metadata") or {},
"content": item.get("content") or item.get("text") or "",
}
)
candidates = self._rank_candidates(normalized_text, candidates)
matched = candidates[0]["table_name"] if candidates else None
return {"table_name": matched, "candidates": candidates, "raw": response}
_GLOBAL_TEMPLATE_MATCHER: TemplateMatcher | None = None
def get_template_matcher() -> TemplateMatcher:
"""获取全局 TemplateMatcher(单例)"""
global _GLOBAL_TEMPLATE_MATCHER
if _GLOBAL_TEMPLATE_MATCHER is None:
_GLOBAL_TEMPLATE_MATCHER = TemplateMatcher()
return _GLOBAL_TEMPLATE_MATCHER