初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+20
View File
@@ -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",
]
+58
View File
@@ -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}
+80
View File
@@ -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
+51
View File
@@ -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