""" 正则规则服务(重构版 - 文件夹结构) 负责加载、管理和应用正则替换规则。 使用文件夹结构组织规则,兼容 SillyTavern 格式。 文件结构: data/regex/ ├── global/ # 全局规则 │ └── default.json ├── characters/ # 角色卡绑定规则 │ └── {characterName}.json └── presets/ # 预设绑定规则 └── {presetName}.json """ import re import json import logging from pathlib import Path from typing import List, Optional, Dict from uuid import uuid4 from core.config import settings from models.regex_rules import RegexRule, RegexRuleset, RegexScope, RegexPlacement, SubstituteMode from services.system_settings_service import system_settings_service logger = logging.getLogger(__name__) class RegexService: """ 正则替换规则服务(文件夹结构版) """ def __init__(self): self.regex_base_path = settings.DATA_PATH / "regex" self.global_path = self.regex_base_path / "global" self.characters_path = self.regex_base_path / "characters" self.presets_path = self.regex_base_path / "presets" # 内存缓存 self.global_rulesets: Dict[str, RegexRuleset] = {} self.character_rulesets: Dict[str, RegexRuleset] = {} # key: characterName self.preset_rulesets: Dict[str, RegexRuleset] = {} # key: presetName self._ensure_directories() self._load_all_rules() def _ensure_directories(self): """确保目录结构存在""" for path in [self.regex_base_path, self.global_path, self.characters_path, self.presets_path]: path.mkdir(parents=True, exist_ok=True) def _load_all_rules(self): """加载所有规则""" self._load_global_rules() self._load_character_rules() self._load_preset_rules() def _load_global_rules(self): """加载全局规则""" if not self.global_path.exists(): return for json_file in self.global_path.glob("*.json"): try: with open(json_file, 'r', encoding='utf-8') as f: data = json.load(f) if isinstance(data, list): # SillyTavern 格式 ruleset = self._convert_sillytavern_format(data, json_file.stem) elif isinstance(data, dict) and 'rules' in data: # 我们的规则集格式 ruleset = RegexRuleset(**data) else: logger.warning(f"未知的规则文件格式: {json_file}") continue self.global_rulesets[ruleset.id] = ruleset logger.info(f"加载全局规则集: {ruleset.name} ({len(ruleset.rules)} 条规则)") except Exception as e: logger.error(f"加载全局规则失败 {json_file}: {e}") def _load_character_rules(self): """加载角色卡绑定规则""" if not self.characters_path.exists(): return for json_file in self.characters_path.glob("*.json"): try: character_name = json_file.stem with open(json_file, 'r', encoding='utf-8') as f: data = json.load(f) if isinstance(data, list): ruleset = self._convert_sillytavern_format(data, character_name, RegexScope.CHARACTER) elif isinstance(data, dict) and 'rules' in data: ruleset = RegexRuleset(**data) else: continue # 确保所有规则的 scope 正确 for rule in ruleset.rules: rule.scope = RegexScope.CHARACTER rule.characterName = character_name self.character_rulesets[character_name] = ruleset logger.info(f"加载角色规则: {character_name} ({len(ruleset.rules)} 条规则)") except Exception as e: logger.error(f"加载角色规则失败 {json_file}: {e}") def _load_preset_rules(self): """加载预设绑定规则""" if not self.presets_path.exists(): return for json_file in self.presets_path.glob("*.json"): try: preset_name = json_file.stem with open(json_file, 'r', encoding='utf-8') as f: data = json.load(f) if isinstance(data, list): ruleset = self._convert_sillytavern_format(data, preset_name, RegexScope.PRESET) elif isinstance(data, dict) and 'rules' in data: ruleset = RegexRuleset(**data) else: continue # 确保所有规则的 scope 正确 for rule in ruleset.rules: rule.scope = RegexScope.PRESET rule.presetName = preset_name self.preset_rulesets[preset_name] = ruleset logger.info(f"加载预设规则: {preset_name} ({len(ruleset.rules)} 条规则)") except Exception as e: logger.error(f"加载预设规则失败 {json_file}: {e}") def _convert_sillytavern_format( self, st_rules: List[dict], name: str, scope: RegexScope = RegexScope.GLOBAL ) -> RegexRuleset: """将 SillyTavern 格式转换为内部格式""" rules = [] for idx, st_rule in enumerate(st_rules): find_regex = st_rule.get('findRegex', '') pattern, flags = self._parse_st_regex(find_regex) # 解析 placement(默认为 AI_OUTPUT) placement_data = st_rule.get('placement', [2]) placement = [RegexPlacement(p) for p in placement_data] rule = RegexRule( id=str(uuid4()), scriptName=st_rule.get('scriptName', f"{name} 规则 {idx + 1}"), findRegex=pattern, replaceString=st_rule.get('replaceString', ''), trimStrings=st_rule.get('trimStrings', []), placement=placement, substituteRegex=SubstituteMode(st_rule.get('substituteRegex', 0)), markdownOnly=st_rule.get('markdownOnly', False), promptOnly=st_rule.get('promptOnly', False), runOnEdit=st_rule.get('runOnEdit', True), minDepth=st_rule.get('minDepth', 0), maxDepth=st_rule.get('maxDepth'), scope=scope, characterName=name if scope == RegexScope.CHARACTER else None, presetName=name if scope == RegexScope.PRESET else None, disabled=st_rule.get('disabled', False), order=idx ) rules.append(rule) ruleset = RegexRuleset( id=str(uuid4()), name=f"{name} 规则集", description=f"从 SillyTavern 导入的规则", rules=rules, isSillyTavernFormat=True ) return ruleset def _parse_st_regex(self, st_regex: str) -> tuple[str, str]: """解析 SillyTavern 的正则表达式格式 /pattern/flags""" if st_regex.startswith('/') and st_regex.count('/') >= 2: parts = st_regex.split('/') pattern = '/'.join(parts[1:-1]) flags = parts[-1] if len(parts) > 2 else '' return pattern, flags else: return st_regex, '' def apply_rules_by_placement( self, text: str, placement: int, character_name: Optional[str] = None, preset_name: Optional[str] = None, message_depth: int = 0, is_for_llm: bool = False, # ✅ 新增:是否发送给LLM is_markdown_rendered: bool = False # ✅ 新增:是否已Markdown渲染 ) -> str: """ 根据 placement 应用正则规则 Args: text: 要处理的文本 placement: 应用位置(0-5) character_name: 当前角色卡名称 preset_name: 当前预设名称 message_depth: 消息深度 is_for_llm: 是否用于发送给 LLM(影响 promptOnly 逻辑) is_markdown_rendered: 是否是 Markdown 渲染后的内容(影响 markdownOnly 逻辑) Returns: 处理后的文本 """ rules = self.get_rules_for_context(character_name, preset_name) result = text for rule in rules: # ✅ SillyTavern 逻辑:根据 markdownOnly 和 promptOnly 决定是否应用 # - 双 false:应用到所有场景(包括保存数据、发送LLM、显示) # - markdownOnly=true:只应用于 Markdown 渲染(前端显示) # - promptOnly=true:只应用于发送给 LLM # - 双 true:应用到所有场景(但不修改存储,由调用方决定) # 如果是保存数据的场景(is_for_llm=False 且 is_markdown_rendered=False) # 只应用双 false 的规则 if not is_for_llm and not is_markdown_rendered: # 保存数据:只应用双 false 的规则 if rule.markdownOnly or rule.promptOnly: continue # 如果是发送给 LLM 的场景 elif is_for_llm and not is_markdown_rendered: # 不应用 markdownOnly=true 且 promptOnly=false 的规则 if rule.markdownOnly and not rule.promptOnly: continue # 如果是 Markdown 渲染的场景(前端显示) elif is_markdown_rendered and not is_for_llm: # 不应用 promptOnly=true 且 markdownOnly=false 的规则 if rule.promptOnly and not rule.markdownOnly: continue # 检查此规则是否适用于当前 placement if placement not in [p.value for p in rule.placement]: continue # 检查消息深度限制 if message_depth < rule.minDepth: continue if rule.maxDepth is not None and message_depth > rule.maxDepth: continue # 应用规则 result = self._apply_single_rule(result, rule) return result def _apply_single_rule(self, text: str, rule: RegexRule) -> str: """应用单条正则规则""" try: flags = 0 if 'i' in rule.findRegex: flags |= re.IGNORECASE if 'm' in rule.findRegex: flags |= re.MULTILINE if 's' in rule.findRegex: flags |= re.DOTALL pattern = rule.findRegex.replace('i', '').replace('m', '').replace('s', '') if rule.substituteRegex == SubstituteMode.REPLACE_FIRST: result = re.sub(pattern, rule.replaceString, text, count=1, flags=flags) else: result = re.sub(pattern, rule.replaceString, text, flags=flags) for trim_str in rule.trimStrings: result = result.replace(trim_str, '') return result except re.error as e: logger.error(f"正则表达式错误 [{rule.scriptName}]: {e}") return text def get_rules_for_context( self, character_name: Optional[str] = None, preset_name: Optional[str] = None ) -> List[RegexRule]: """ 根据上下文获取适用的规则列表 优先级:全局规则 + 角色规则 + 预设规则 """ applicable_rules = [] # 1. 加载全局规则 for ruleset in self.global_rulesets.values(): for rule in ruleset.rules: if not rule.disabled: applicable_rules.append(rule) # 2. 加载角色卡规则 if character_name and character_name in self.character_rulesets: ruleset = self.character_rulesets[character_name] for rule in ruleset.rules: if not rule.disabled: applicable_rules.append(rule) # 3. 加载预设规则 if preset_name and preset_name in self.preset_rulesets: ruleset = self.preset_rulesets[preset_name] for rule in ruleset.rules: if not rule.disabled: applicable_rules.append(rule) # 按 order 排序 applicable_rules.sort(key=lambda r: r.order) return applicable_rules def save_ruleset(self, ruleset: RegexRuleset, scope: RegexScope, name: Optional[str] = None): """保存规则集到文件""" if scope == RegexScope.GLOBAL: file_path = self.global_path / f"{ruleset.id}.json" elif scope == RegexScope.CHARACTER: file_path = self.characters_path / f"{name or 'unknown'}.json" elif scope == RegexScope.PRESET: file_path = self.presets_path / f"{name or 'unknown'}.json" else: raise ValueError(f"未知的作用域: {scope}") with open(file_path, 'w', encoding='utf-8') as f: json.dump(ruleset.dict(), f, ensure_ascii=False, indent=2) logger.info(f"保存规则集到: {file_path}") def delete_ruleset(self, scope: RegexScope, name: str): """删除规则集""" if scope == RegexScope.CHARACTER: file_path = self.characters_path / f"{name}.json" elif scope == RegexScope.PRESET: file_path = self.presets_path / f"{name}.json" else: raise ValueError(f"不能删除全局规则集") if file_path.exists(): file_path.unlink() logger.info(f"删除规则集: {file_path}") # 全局实例 regex_service = RegexService()