初始化提交

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

+5
View File
@@ -0,0 +1,5 @@
"""资料导入领域逻辑(纯函数,无 IO)。"""
from app.modules.imports.parsing import ParsedItemDraft, parse_import
__all__ = ["ParsedItemDraft", "parse_import"]
+120
View File
@@ -0,0 +1,120 @@
"""酒馆角色卡解析器。
从 PNG(``tEXt`` chunk 的 ``chara``/``ccv3``)/ JSON / CHARX(zip 含 ``card.json``)
解析角色卡,归一化为统一的 ``CharacterCard``(V2/V3 的 ``data`` 字段拍平)。
纯字节 / 文本解析,无文件 IO——调用方(service 层)负责读文件后传入字节。
V1 扁平结构自动归一化;``character_book`` 原样保留,由映射层转世谱条目。
"""
from __future__ import annotations
import base64
import io
import json
import zipfile
from dataclasses import dataclass, field
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
_ZIP_SIGNATURE = b"PK\x03\x04"
@dataclass(frozen=True)
class CharacterCard:
"""归一化后的角色卡(V1/V2/V3 统一为拍平字段)。"""
spec_version: str # "1" / "2.0" / "3.0"
name: str = ""
description: str = ""
personality: str = ""
scenario: str = ""
first_mes: str = ""
mes_example: str = ""
creator_notes: str = ""
system_prompt: str = ""
post_history_instructions: str = ""
alternate_greetings: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
creator: str = ""
character_version: str = ""
character_book: dict | None = None # 原始 lorebook,由映射层转 LorebookBook
nickname: str | None = None # V3
extensions: dict = field(default_factory=dict) # 保留未知键(酒馆规范:不得删除)
def parse_character_card(raw: bytes) -> CharacterCard:
"""从原始字节解析角色卡(自动识别 PNG / JSON / CHARX)。"""
if raw[:8] == _PNG_SIGNATURE:
data = _parse_png(raw)
elif raw[:4] == _ZIP_SIGNATURE:
data = _parse_charx(raw)
else:
data = json.loads(raw.decode("utf-8-sig"))
return _normalize(data)
def _parse_png(raw: bytes) -> dict:
"""从 PNG tEXt chunk 提取 chara / ccv3(base64 → JSON)。优先 ccv3。"""
chunks: list[tuple[str, str]] = []
offset = len(_PNG_SIGNATURE)
while offset + 8 <= len(raw):
length = int.from_bytes(raw[offset : offset + 4], "big")
chunk_type = raw[offset + 4 : offset + 8]
data_start = offset + 8
data_end = data_start + length
if data_end > len(raw):
break
if chunk_type == b"tEXt":
null = raw[data_start:data_end].find(b"\x00")
if null >= 0:
keyword = raw[data_start : data_start + null].decode("latin-1")
text = raw[data_start + null + 1 : data_end].decode("latin-1")
chunks.append((keyword, text))
offset = data_end + 4 # 跳过 CRC
for want in ("ccv3", "chara"):
for keyword, text in chunks:
if keyword == want:
decoded = base64.b64decode(text)
return json.loads(decoded.decode("utf-8"))
raise ValueError("PNG 中未找到角色卡元数据(chara / ccv3 chunk)")
def _parse_charx(raw: bytes) -> dict:
"""从 CHARX(zip)提取 card.json。"""
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
card_name = next((n for n in zf.namelist() if n.endswith("card.json")), None)
if card_name is None:
raise ValueError("CHARX 中未找到 card.json")
with zf.open(card_name) as f:
return json.loads(f.read().decode("utf-8"))
def _normalize(data: dict) -> CharacterCard:
"""V1 扁平结构归一化为 V2/V3(data 包裹)。"""
if isinstance(data.get("data"), dict):
inner = data["data"]
spec_version = str(data.get("spec_version", "2.0"))
else:
inner = data
spec_version = "1"
return CharacterCard(
spec_version=spec_version,
name=inner.get("name", ""),
description=inner.get("description", ""),
personality=inner.get("personality", ""),
scenario=inner.get("scenario", ""),
first_mes=inner.get("first_mes", ""),
mes_example=inner.get("mes_example", ""),
creator_notes=inner.get("creator_notes", ""),
system_prompt=inner.get("system_prompt", ""),
post_history_instructions=inner.get("post_history_instructions", ""),
alternate_greetings=list(inner.get("alternate_greetings", []) or []),
tags=list(inner.get("tags", []) or []),
creator=inner.get("creator", ""),
character_version=inner.get("character_version", ""),
character_book=inner.get("character_book"),
nickname=inner.get("nickname"),
extensions=dict(inner.get("extensions", {}) or {}),
)
@@ -0,0 +1,71 @@
"""角色卡导出映射:魂镜 Character + 世谱 → ST V3 卡静态快照。
反向于 import mapping。导出为静态快照——丢弃演化维度(人格版本 / 记忆 / 情绪),
仅保留当前设定 + 世谱。``mission`` / ``safety_guidelines`` 道体无 ST 对应字段,不导出。
"""
from __future__ import annotations
import json
from app.models.character import Character
from app.models.lorebook import Lorebook, LorebookEntry
def character_to_card(
character: Character,
lorebook: Lorebook | None = None,
entries: list[LorebookEntry] | None = None,
) -> dict:
"""Character + 世谱 → ST V3 卡 dict。"""
profile = json.loads(character.profile_json or "{}")
source_meta = profile.get("source_meta") or {}
data: dict = {
"name": character.name,
"description": profile.get("background", ""),
"personality": character.personality_summary or "",
"scenario": profile.get("scenario", ""),
"first_mes": profile.get("first_mes", ""),
"mes_example": profile.get("mes_example", ""),
"alternate_greetings": profile.get("alternate_greetings", []),
"tags": profile.get("tags", []),
"creator": source_meta.get("creator", ""),
"character_version": source_meta.get("character_version", ""),
"creator_notes": source_meta.get("creator_notes", ""),
"system_prompt": profile.get("system_prompt", ""),
"post_history_instructions": profile.get("post_history_instructions", ""),
"extensions": profile.get("st_extensions", {}),
}
if character.display_name and character.display_name != character.name:
data["nickname"] = character.display_name
if lorebook is not None:
data["character_book"] = lorebook_to_st_book(lorebook, entries or [])
return {"spec": "chara_card_v3", "spec_version": "3.0", "data": data}
def lorebook_to_st_book(lorebook: Lorebook, entries: list[LorebookEntry]) -> dict:
return {
"name": lorebook.name,
"description": lorebook.description,
"scan_depth": lorebook.scan_depth,
"token_budget": lorebook.token_budget,
"recursive_scanning": lorebook.recursive_scanning,
"entries": [entry_to_st(e) for e in entries],
}
def entry_to_st(entry: LorebookEntry) -> dict:
return {
"keys": json.loads(entry.keys_json or "[]"),
"content": entry.content,
"secondary_keys": json.loads(entry.secondary_keys_json or "[]"),
"constant": entry.constant,
"selective": entry.selective,
"enabled": entry.enabled,
"case_sensitive": entry.case_sensitive,
"use_regex": entry.use_regex,
"position": entry.position,
"insertion_order": entry.insertion_order,
"priority": entry.priority,
"comment": entry.comment,
}
+143
View File
@@ -0,0 +1,143 @@
"""解析模型返回为候选草稿(纯函数,无 IO)。
模型可能返回纯 JSON、markdown 代码块或夹杂解释文本;本模块容错提取首个 JSON 数组,
并归一化为候选草稿。evidence 序号由 application 映射为 ``import_items.id``。
"""
import json
import re
from dataclasses import dataclass, field
from typing import Any
@dataclass
class ProfileCandidateDraft:
field_name: str
value: str
confidence: float
reason: str
evidence: list[int] = field(default_factory=list)
@dataclass
class MemoryCandidateDraft:
type: str
title: str
content: str
importance: float
sensitivity: str
reason: str
evidence: list[int] = field(default_factory=list)
@dataclass
class PersonalityProposalDraft:
field_name: str
change: str
reason: str
evidence: list[int] = field(default_factory=list)
def parse_profile_candidates(raw: str) -> list[ProfileCandidateDraft]:
drafts: list[ProfileCandidateDraft] = []
for item in _extract_json_array(raw):
if not isinstance(item, dict):
continue
field_name = str(item.get("field", "")).strip()
value = str(item.get("value", "")).strip()
if not field_name or not value:
continue
drafts.append(
ProfileCandidateDraft(
field_name=field_name,
value=value,
confidence=_clamp_float(item.get("confidence"), 0.5),
reason=str(item.get("reason", "")).strip(),
evidence=_int_list(item.get("evidence")),
)
)
return drafts
def parse_memory_candidates(raw: str) -> list[MemoryCandidateDraft]:
drafts: list[MemoryCandidateDraft] = []
for item in _extract_json_array(raw):
if not isinstance(item, dict):
continue
title = str(item.get("title", "")).strip()
content = str(item.get("content", "")).strip()
if not title or not content:
continue
drafts.append(
MemoryCandidateDraft(
type=str(item.get("type", "other")).strip() or "other",
title=title,
content=content,
importance=_clamp_float(item.get("importance"), 0.5),
sensitivity=_sensitivity(item.get("sensitivity")),
reason=str(item.get("reason", "")).strip(),
evidence=_int_list(item.get("evidence")),
)
)
return drafts
def parse_personality_proposals(raw: str) -> list[PersonalityProposalDraft]:
drafts: list[PersonalityProposalDraft] = []
for item in _extract_json_array(raw):
if not isinstance(item, dict):
continue
field_name = str(item.get("field", "")).strip()
change = str(item.get("change", "")).strip()
if not field_name or not change:
continue
drafts.append(
PersonalityProposalDraft(
field_name=field_name,
change=change,
reason=str(item.get("reason", "")).strip(),
evidence=_int_list(item.get("evidence")),
)
)
return drafts
def _extract_json_array(raw: str) -> list[Any]:
text = raw.strip()
matched = re.search(r"```(?:json)?\s*(\[.*?\])\s*```", text, re.DOTALL)
if matched:
text = matched.group(1)
else:
start, end = text.find("["), text.rfind("]")
if start != -1 and end > start:
text = text[start : end + 1]
try:
data = json.loads(text)
except json.JSONDecodeError:
return []
return data if isinstance(data, list) else []
def _clamp_float(value: Any, default: float) -> float:
try:
num = float(value)
except (TypeError, ValueError):
return default
return max(0.0, min(1.0, num))
def _int_list(value: Any) -> list[int]:
if not isinstance(value, list):
return []
result: list[int] = []
for item in value:
try:
result.append(int(item))
except (TypeError, ValueError):
continue
return result
def _sensitivity(value: Any) -> str:
text = str(value or "").strip().lower()
return text if text in {"low", "medium", "high"} else "low"
+66
View File
@@ -0,0 +1,66 @@
"""角色卡字段映射:ST CharacterCard → 魂镜 Character + 世谱条目。
纯逻辑,无 IO。``system_prompt`` / ``post_history_instructions`` 导入时存入 profile,
运行时作道体增补层注入(绝不覆盖恒定层,见融合设计 §6)。
"""
from __future__ import annotations
from app.modules.imports.card_parser import CharacterCard
def card_to_profile(card: CharacterCard) -> dict:
"""ST 卡 → Character profile JSON(自由字段,保留酒馆全部信息)。"""
return {
"background": card.description,
"scenario": card.scenario,
"first_mes": card.first_mes,
"mes_example": card.mes_example,
"alternate_greetings": card.alternate_greetings,
"tags": card.tags,
# 运行时作道体增补层注入(§6 安全边界),导入先保存
"system_prompt": card.system_prompt,
"post_history_instructions": card.post_history_instructions,
"source_meta": {
"creator": card.creator,
"character_version": card.character_version,
"creator_notes": card.creator_notes,
"spec_version": card.spec_version,
},
"st_extensions": card.extensions, # 保留未知键(酒馆规范:不得删除)
}
def card_to_daoti(card: CharacterCard) -> dict:
"""ST 卡 → 道体字段。
``personality`` 作 ``personality_summary`` 初始值(已拍板);``mission`` /
``safety_guidelines`` 酒馆卡没有,导入不设,留用户 / 演化填。
"""
return {"personality_summary": card.personality or None}
def character_book_to_entries(book: dict | None) -> list[dict]:
"""ST character_book entries → 世谱条目构造参数。"""
if not book:
return []
result = []
for e in book.get("entries") or []:
enabled_raw = e.get("enabled", True)
result.append(
{
"keys": list(e.get("keys") or []),
"content": e.get("content") or "",
"secondary_keys": list(e.get("secondary_keys") or []),
"constant": bool(e.get("constant", False)),
"selective": bool(e.get("selective", False)),
"enabled": True if enabled_raw is None else bool(enabled_raw),
"case_sensitive": bool(e.get("case_sensitive", False)),
"use_regex": bool(e.get("use_regex", False)),
"position": e.get("position") or "after_char",
"insertion_order": int(e.get("insertion_order") or 100),
"priority": int(e.get("priority") or 10),
"comment": e.get("comment") or e.get("name") or "",
}
)
return result
+113
View File
@@ -0,0 +1,113 @@
"""导入资料结构解析(纯函数,无 IO)。
按 ``material_type`` / ``format`` 把原始资料切片为条目草稿。只做结构解析
(消息数组、段落、字段),语义抽取(档案 / 记忆 / 人格候选)留给后续
模型驱动阶段。领域层禁止 IO,本模块仅产出数据结构。
"""
import json
from dataclasses import dataclass, field
from typing import Any
# 5a 不切片的媒体资料(仅存元数据与授权记录,spec §18 仅预留)。
_NON_TEXT_MATERIAL_TYPES = {"voice_sample"}
@dataclass
class ParsedItemDraft:
"""解析产出的条目草稿;落库前由 service 补 sequence / id。"""
item_type: str
role: str
content: str
metadata: dict[str, Any] = field(default_factory=dict)
def parse_import(
material_type: str, fmt: str, *, content: str | None = None
) -> list[ParsedItemDraft]:
"""按格式解析文本资料;媒体资料返回空。"""
if material_type in _NON_TEXT_MATERIAL_TYPES:
return []
if fmt == "json" and content:
return _parse_json(content)
if content:
return _parse_paragraphs(content)
return []
def _parse_json(content: str) -> list[ParsedItemDraft]:
try:
data = json.loads(content)
except json.JSONDecodeError:
return _parse_paragraphs(content)
if (
isinstance(data, list)
and data
and isinstance(data[0], dict)
and ("role" in data[0] or "content" in data[0])
):
return _parse_messages(data)
if isinstance(data, dict):
return _parse_fields(data)
return _parse_paragraphs(content)
def _parse_messages(messages: list[dict[str, Any]]) -> list[ParsedItemDraft]:
drafts: list[ParsedItemDraft] = []
for msg in messages:
if not isinstance(msg, dict):
continue
text = str(msg.get("content", "")).strip()
if not text:
continue
meta = {k: v for k, v in msg.items() if k not in ("role", "content")}
drafts.append(
ParsedItemDraft(
item_type="message",
role=_normalize_role(str(msg.get("role", "other"))),
content=text,
metadata=meta,
)
)
return drafts
def _parse_fields(data: dict[str, Any]) -> list[ParsedItemDraft]:
drafts: list[ParsedItemDraft] = []
for key, value in data.items():
text = str(value).strip()
if text:
drafts.append(
ParsedItemDraft(
item_type="profile_field",
role="other",
content=text,
metadata={"field": key},
)
)
return drafts
def _parse_paragraphs(text: str) -> list[ParsedItemDraft]:
drafts: list[ParsedItemDraft] = []
for chunk in text.split("\n\n"):
clean = chunk.strip()
if clean:
drafts.append(ParsedItemDraft(item_type="paragraph", role="other", content=clean))
return drafts
def _normalize_role(role: str) -> str:
lowered = role.lower()
if lowered in ("user", "human"):
return "user"
if lowered in ("assistant", "ai", "bot"):
return "assistant"
if lowered == "system":
return "system"
return "other"
+85
View File
@@ -0,0 +1,85 @@
"""候选抽取 prompt 构建(纯函数,无 IO)。
接收 ``(role, content)`` 元组列表(由 application 从 ORM 转换),产出
``(system, user)`` 消息对,喂给模型网关。模型返回的 JSON 由 ``extract`` 模块解析。
"""
from collections.abc import Sequence
PROFILE_FIELDS = (
"display_name",
"birthday",
"height",
"weight",
"background",
"speaking_style",
)
MEMORY_TYPES = (
"user",
"preference",
"event",
"relationship",
"skill",
"goal",
"other",
)
PERSONALITY_FIELDS = (
"identity_profile",
"role_destiny",
"personality_seed",
"expression_style",
"relationship_pattern",
"traits",
"values",
"emotion_baseline",
)
def _format_items(items: Sequence[tuple[str, str]]) -> str:
lines = [f"[{idx}] ({role}) {content}" for idx, (role, content) in enumerate(items)]
return "\n".join(lines)
def build_profile_extraction_messages(
items: Sequence[tuple[str, str]],
) -> tuple[str, str]:
system = (
"你是资料分析助手。从用户提供的资料中提取人物档案字段候选,只提取明确出现的信息。"
f"field 取自:{', '.join(PROFILE_FIELDS)}。"
'以 JSON 数组返回,每个元素:{"field": str, "value": str, "confidence": 0~1, '
'"reason": str, "evidence": [条目序号]}。'
"evidence 为资料条目序号([0]/[1]…)列表。若资料无档案信息,返回 []。"
"只返回 JSON 数组,不要解释或 markdown。"
)
return system, f"资料:\n{_format_items(items)}"
def build_memory_extraction_messages(
items: Sequence[tuple[str, str]],
) -> tuple[str, str]:
system = (
"你是记忆分析助手。从用户提供的资料中提取值得长期记住的记忆候选。"
f"type 取自:{', '.join(MEMORY_TYPES)}。"
'以 JSON 数组返回:{"type": str, "title": str, "content": str, '
'"importance": 0~1, "sensitivity": "low|medium|high", "reason": str, '
'"evidence": [条目序号]}。'
"只提取有长期价值的非琐碎信息。若无可提取返回 []。只返回 JSON 数组。"
)
return system, f"资料:\n{_format_items(items)}"
def build_personality_extraction_messages(
items: Sequence[tuple[str, str]], personality_summary: str
) -> tuple[str, str]:
system = (
"你是人格分析助手。基于用户提供的资料和当前人格,提出人格完善建议。"
f"field 取自:{', '.join(PERSONALITY_FIELDS)}。"
'以 JSON 数组返回:{"field": str, "change": str, "reason": str, '
'"evidence": [条目序号]}。'
"change 为建议写入该字段的新内容。只在资料充分支撑时提议。若无需完善返回 []。"
"只返回 JSON 数组。"
)
user = (
f"当前人格摘要:\n{personality_summary or '(尚未形成)'}\n\n资料:\n{_format_items(items)}"
)
return system, user