初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,20 @@
|
||||
"""世谱(Lorebook / World Info)—— 关键词触发的世界设定注入。
|
||||
|
||||
纯逻辑模块(禁 IO):匹配 / 预算裁剪 / 注入分组。
|
||||
IO(查条目、扫对话历史)由集成层(send_message)负责,结果以值传入。
|
||||
设计见 docs/contracts/contract-glossary.md §7。
|
||||
"""
|
||||
|
||||
from app.modules.lorebook.budget import TokenCounter, build_injections, select_within_budget
|
||||
from app.modules.lorebook.matching import match_entries
|
||||
from app.modules.lorebook.types import LorebookBook, LorebookEntry, MatchedEntry
|
||||
|
||||
__all__ = [
|
||||
"LorebookBook",
|
||||
"LorebookEntry",
|
||||
"MatchedEntry",
|
||||
"TokenCounter",
|
||||
"build_injections",
|
||||
"match_entries",
|
||||
"select_within_budget",
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""世谱预算裁剪与注入分组。
|
||||
|
||||
按 token 预算裁剪(priority 值小优先保留),再按 insertion_order 排定顺序,
|
||||
最后按 position 分组为可注入的文本块。纯逻辑,无 IO。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from app.modules.lorebook.types import MatchedEntry
|
||||
|
||||
TokenCounter = Callable[[str], int]
|
||||
|
||||
_POSITIONS = ("before_char", "after_char")
|
||||
|
||||
|
||||
def _default_token_counter(text: str) -> int:
|
||||
"""P0 以字符数近似 token;后续接神识 tokenizer。"""
|
||||
return len(text)
|
||||
|
||||
|
||||
def select_within_budget(
|
||||
matched: list[MatchedEntry],
|
||||
token_budget: int | None = None,
|
||||
token_counter: TokenCounter | None = None,
|
||||
) -> list[MatchedEntry]:
|
||||
"""按 priority 选入预算内条目,再按 insertion_order 排定注入顺序。"""
|
||||
counter = token_counter or _default_token_counter
|
||||
if token_budget is None:
|
||||
return sorted(matched, key=lambda m: m.entry.insertion_order)
|
||||
|
||||
by_priority = sorted(matched, key=lambda m: m.entry.priority)
|
||||
kept: list[MatchedEntry] = []
|
||||
total = 0
|
||||
for m in by_priority:
|
||||
cost = counter(m.entry.content)
|
||||
if cost == 0:
|
||||
continue
|
||||
if total + cost > token_budget:
|
||||
continue
|
||||
kept.append(m)
|
||||
total += cost
|
||||
kept.sort(key=lambda m: m.entry.insertion_order)
|
||||
return kept
|
||||
|
||||
|
||||
def build_injections(matched: list[MatchedEntry]) -> dict[str, str]:
|
||||
"""按 position 分组,合并同组条目 content 为单段文本。
|
||||
|
||||
返回 ``{"before_char": "...", "after_char": "..."}``,空组不出现在结果中。
|
||||
集成层据此把 before_char 插在道体 personality_summary 之前、after_char 之后。
|
||||
"""
|
||||
groups: dict[str, list[str]] = {pos: [] for pos in _POSITIONS}
|
||||
for m in matched:
|
||||
pos = m.entry.position if m.entry.position in groups else "after_char"
|
||||
groups[pos].append(m.entry.content)
|
||||
return {pos: "\n".join(parts) for pos, parts in groups.items() if parts}
|
||||
@@ -0,0 +1,80 @@
|
||||
"""世谱匹配引擎:关键词 / 正则 / selective / constant / 递归扫描。
|
||||
|
||||
纯逻辑,无 IO。扫描文本由调用方(集成层)从对话历史截取后传入。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from app.modules.lorebook.types import LorebookBook, LorebookEntry, MatchedEntry
|
||||
|
||||
_RECURSION_MAX_ITERATIONS = 5
|
||||
|
||||
|
||||
def match_entries(book: LorebookBook, scan_text: str) -> list[MatchedEntry]:
|
||||
"""返回命中的条目(含 constant 与关键词命中);递归扫描按 book 配置。"""
|
||||
matched: list[MatchedEntry] = []
|
||||
for entry in book.entries:
|
||||
if not entry.enabled:
|
||||
continue
|
||||
if entry.constant:
|
||||
matched.append(MatchedEntry(entry=entry, reason="constant"))
|
||||
continue
|
||||
if _entry_matches(entry, scan_text):
|
||||
matched.append(MatchedEntry(entry=entry, reason="key"))
|
||||
|
||||
if book.recursive_scanning:
|
||||
matched = _expand_recursive(matched, book, scan_text)
|
||||
return matched
|
||||
|
||||
|
||||
def _entry_matches(entry: LorebookEntry, scan_text: str) -> bool:
|
||||
if entry.selective and entry.secondary_keys:
|
||||
return _any_key_hit(entry.keys, entry, scan_text) and _any_key_hit(
|
||||
entry.secondary_keys, entry, scan_text
|
||||
)
|
||||
return _any_key_hit(entry.keys, entry, scan_text)
|
||||
|
||||
|
||||
def _any_key_hit(keys: list[str], entry: LorebookEntry, scan_text: str) -> bool:
|
||||
for key in keys:
|
||||
if not key:
|
||||
continue
|
||||
if entry.use_regex:
|
||||
flags = 0 if entry.case_sensitive else re.IGNORECASE
|
||||
try:
|
||||
if re.search(key, scan_text, flags):
|
||||
return True
|
||||
except re.error:
|
||||
# 非法正则降级为字面量包含
|
||||
pass
|
||||
else:
|
||||
hay = scan_text if entry.case_sensitive else scan_text.casefold()
|
||||
needle = key if entry.case_sensitive else key.casefold()
|
||||
if needle in hay:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _expand_recursive(
|
||||
matched: list[MatchedEntry], book: LorebookBook, original_text: str
|
||||
) -> list[MatchedEntry]:
|
||||
"""已命中条目的 content 追加进扫描文本,再扫未命中条目;限迭代防振荡。"""
|
||||
hit_ids = {m.entry.id for m in matched}
|
||||
pool = list(matched)
|
||||
extra_text = original_text + "\n" + "\n".join(m.entry.content for m in matched)
|
||||
|
||||
for _ in range(_RECURSION_MAX_ITERATIONS):
|
||||
grew = False
|
||||
for entry in book.entries:
|
||||
if entry.id in hit_ids or not entry.enabled or entry.constant:
|
||||
continue
|
||||
if _entry_matches(entry, extra_text):
|
||||
pool.append(MatchedEntry(entry=entry, reason="recursive"))
|
||||
hit_ids.add(entry.id)
|
||||
extra_text += "\n" + entry.content
|
||||
grew = True
|
||||
if not grew:
|
||||
break
|
||||
return pool
|
||||
@@ -0,0 +1,51 @@
|
||||
"""世谱(Lorebook / World Info)数据结构。
|
||||
|
||||
兼容酒馆 V2/V3 条目字段 + 魂镜扩展(绑定河图实体、作用域)。
|
||||
枚举取值(position / scope)以契约字典为准;本文仅定义载体。
|
||||
设计见 docs/contracts/contract-glossary.md §7(世谱)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LorebookEntry(BaseModel):
|
||||
"""单条世谱条目:关键词命中后注入其 content。"""
|
||||
|
||||
id: str
|
||||
keys: list[str] = Field(default_factory=list)
|
||||
content: str = ""
|
||||
secondary_keys: list[str] = Field(default_factory=list)
|
||||
constant: bool = False # 恒定注入(预算内始终命中)
|
||||
selective: bool = False # 为真时需同时命中 keys 与 secondary_keys
|
||||
enabled: bool = True
|
||||
case_sensitive: bool = False
|
||||
use_regex: bool = False # 为真时 keys 按正则匹配
|
||||
# before_char:角色定义(道体 personality_summary)之前;after_char:之后
|
||||
position: str = "after_char"
|
||||
insertion_order: int = 100 # 值越小,注入位置越靠前
|
||||
priority: int = 10 # 预算紧张时值越小越先保留
|
||||
comment: str = ""
|
||||
# 魂镜扩展:绑定河图世界实体,实体被提及时触发(关键词之外的第二引擎)
|
||||
bound_entity_id: str | None = None
|
||||
|
||||
|
||||
class LorebookBook(BaseModel):
|
||||
"""一本世谱:条目集合 + 扫描/预算参数。"""
|
||||
|
||||
id: str
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
entries: list[LorebookEntry] = Field(default_factory=list)
|
||||
scan_depth: int | None = None # 扫描最近 N 条对话;None 表示扫全部传入文本
|
||||
token_budget: int | None = None # 注入 token 预算上限;None 表示不裁剪
|
||||
recursive_scanning: bool = False # 已命中条目 content 可触发其他条目
|
||||
scope: str = "character" # character | global
|
||||
|
||||
|
||||
class MatchedEntry(BaseModel):
|
||||
"""一次匹配的结果:命中的条目 + 命中原因。"""
|
||||
|
||||
entry: LorebookEntry
|
||||
reason: str = "key" # constant | key | recursive
|
||||
Reference in new issue
Block a user