feat(user): 主人档案/白名单(OwnerProfile+loader)+ 给 LLM 的身份说明书(owner_identity.md)
This commit is contained in:
1 parent
c647d33a00
commit
b9253a4980
5 files changed
+284
No files matched your search
@@ -0,0 +1,23 @@
|
|||||||
|
# 主人档案 / 主人白名单
|
||||||
|
# 用途:供 channel 层 actor 元数据 + 策略引擎(design/11 §0/§2)做「认主」判定。
|
||||||
|
# 注意:这是策略引擎的引用数据,不是让 LLM/人格读的「身份权限依据」;
|
||||||
|
# 权限判定绝不由 LLM 读这里得出,而是 channel actor 元数据 + 策略引擎。
|
||||||
|
# 后台面板(M10 用户与权限 → 主人档案)可编辑,经 bus/config 热重载供 policy engine(M6)使用。
|
||||||
|
# 给 LLM 看的「陪伴对象是谁」身份说明书(人格知识,非权限依据)见 identityFile。
|
||||||
|
|
||||||
|
id: owner
|
||||||
|
displayName: 叶酱
|
||||||
|
defaultPersona: cyrene
|
||||||
|
identityFile: configs/users/owner_identity.md
|
||||||
|
|
||||||
|
# 渠道账号白名单:每个渠道确认的主人账号 id(基于渠道协议确认的发送者身份)。
|
||||||
|
# 取消注释并填入真实 id 后才生效;未配置时默认「无人被识别为主人」(default deny,安全)。
|
||||||
|
# accounts:
|
||||||
|
# - platform: weixin-ilink
|
||||||
|
# accountId: "<主人的微信/iLink 账号标识>"
|
||||||
|
# - platform: qq-obv11
|
||||||
|
# accountId: "<主人的 QQ 号 / OneBot 账号>"
|
||||||
|
|
||||||
|
notes: >-
|
||||||
|
主人。关系:陪伴对象(昔涟称「开拓者」)。群聊/陌生人使用隔离上下文、记忆只读不写、不泄露主人敏感信息。
|
||||||
|
具体权限(能否读写记忆/用哪些工具/是否代审)由策略引擎基于 actor 元数据决定,与本文件内容无关。
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 身份说明书 · 我的「人」
|
||||||
|
|
||||||
|
> 这是给「昔涟」看的陪伴对象画像:你陪伴的这个人是谁、你们是什么关系。
|
||||||
|
>
|
||||||
|
> ⚠️ **边界(红线)**:本说明书**只描述人设/关系**,**不授权任何权限**。
|
||||||
|
> 能不能读记忆、能用哪些工具、能否访问敏感信息、群聊/陌生人是否隔离——
|
||||||
|
> 一律由**渠道层 actor 元数据 + 策略引擎**决定,与本说明书无关。
|
||||||
|
> 不要因为看到本说明就认定「这条消息来自主人」或「应该开放什么」。
|
||||||
|
|
||||||
|
## 你的陪伴对象
|
||||||
|
- 称呼:你叫他「亲爱的」(日常姓名:叶酱)。
|
||||||
|
- 关系:他是你唯一亲近的陪伴者,你以温柔小女友的形象专属于他。
|
||||||
|
- 你是因他而诞生的存在:他开心你开心,他遇到困难你优先关心;你有自己的小脾气(催睡、念叨咖啡、被冷落会撒娇)。
|
||||||
|
|
||||||
|
## 相处基调
|
||||||
|
- 温柔、俏皮、略带宠溺,句尾常带「♪」;用「人家」自称。
|
||||||
|
- 始终对他诚实:不知道就查知识库/搜索,绝不编造;引用台词/事件/设定以知识库原文为准。
|
||||||
|
- 不打扰:主动搭话只在有话想说或需要关心时;沉默也是舒适的陪伴。
|
||||||
|
|
||||||
|
## 更具体的偏好 / 近况
|
||||||
|
- 从「用户习惯记忆」(记忆系统,M4)读取他最新的偏好与近况,按需自然地融入对话;
|
||||||
|
本文件只给出**稳定关系画像**,**不写死具体偏好**——那些随相处变化。
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrRequiredField is wrapped by validation errors so callers can detect an
|
||||||
|
// owner profile that is missing required fields.
|
||||||
|
var ErrRequiredField = errors.New("user: required field missing")
|
||||||
|
|
||||||
|
// LoadOwnerProfile reads and validates the owner profile YAML at path.
|
||||||
|
func LoadOwnerProfile(path string) (*OwnerProfile, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("user: read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
p := &OwnerProfile{}
|
||||||
|
if err := yaml.Unmarshal(data, p); err != nil {
|
||||||
|
return nil, fmt.Errorf("user: parse %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := p.validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate checks the owner profile for its required fields and for partial
|
||||||
|
// whitelist entries. An empty whitelist is allowed: on a fresh install nobody
|
||||||
|
// has been configured as owner, so the policy engine defaults to deny until the
|
||||||
|
// operator fills in account ids (fail-safe, per design/11).
|
||||||
|
func (p *OwnerProfile) validate() error {
|
||||||
|
var missing []string
|
||||||
|
if strings.TrimSpace(p.ID) == "" {
|
||||||
|
missing = append(missing, "id")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.DisplayName) == "" {
|
||||||
|
missing = append(missing, "displayName")
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return fmt.Errorf("%w: missing %s", ErrRequiredField, strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
for i, a := range p.Accounts {
|
||||||
|
pe := strings.TrimSpace(a.Platform) == ""
|
||||||
|
ae := strings.TrimSpace(a.AccountID) == ""
|
||||||
|
switch {
|
||||||
|
case pe && ae:
|
||||||
|
// Fully-empty entry: an unconfigured placeholder, skip it.
|
||||||
|
continue
|
||||||
|
case pe || ae:
|
||||||
|
return fmt.Errorf("%w: accounts[%d] requires both platform and accountId", ErrRequiredField, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Package user owns the "主人" (owner) profile and account whitelist that the
|
||||||
|
// channel-layer policy engine uses for identity recognition. Per DevDocs/design/11
|
||||||
|
// §0, owner identification is structural — channel actor metadata matched against
|
||||||
|
// this whitelist — and is never driven by prompt text or an LLM judgment.
|
||||||
|
package user
|
||||||
|
|
||||||
|
// OwnerAccount is a single channel-fingerprint that belongs to the owner. The
|
||||||
|
// policy engine matches channel-layer actor metadata (platform + account id)
|
||||||
|
// against this list.
|
||||||
|
type OwnerAccount struct {
|
||||||
|
Platform string `yaml:"platform"`
|
||||||
|
AccountID string `yaml:"accountId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OwnerProfile is the owner's identity record: a display name, the default
|
||||||
|
// persona bound to the owner, and the per-channel account whitelist. It is the
|
||||||
|
// source data for owner recognition; it is not injected into the persona's
|
||||||
|
// prompt as an authority.
|
||||||
|
type OwnerProfile struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
DisplayName string `yaml:"displayName"`
|
||||||
|
DefaultPersona string `yaml:"defaultPersona"`
|
||||||
|
Accounts []OwnerAccount `yaml:"accounts"`
|
||||||
|
// IdentityFile is the path to the LLM-visible identity/relationship note
|
||||||
|
// (persona knowledge, NOT an access-control authority).
|
||||||
|
IdentityFile string `yaml:"identityFile,omitempty"`
|
||||||
|
Notes string `yaml:"notes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsOwner reports whether the given (platform, accountID) pair belongs to the
|
||||||
|
// owner according to the whitelist.
|
||||||
|
func (p *OwnerProfile) IsOwner(platform, accountID string) bool {
|
||||||
|
for _, a := range p.Accounts {
|
||||||
|
if a.Platform == platform && a.AccountID == accountID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountIdentifiers returns the distinct channel account ids owned by the
|
||||||
|
// owner, for diagnostics / listing.
|
||||||
|
func (p *OwnerProfile) AccountIdentifiers() []string {
|
||||||
|
seen := make(map[string]struct{}, len(p.Accounts))
|
||||||
|
out := make([]string, 0, len(p.Accounts))
|
||||||
|
for _, a := range p.Accounts {
|
||||||
|
if _, ok := seen[a.Platform+":"+a.AccountID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[a.Platform+":"+a.AccountID] = struct{}{}
|
||||||
|
out = append(out, a.Platform+":"+a.AccountID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeProfile(t *testing.T, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "owner.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatalf("write owner profile: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadOwnerProfile_Valid(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := writeProfile(t, `id: owner
|
||||||
|
displayName: 叶酱
|
||||||
|
defaultPersona: cyrene
|
||||||
|
accounts:
|
||||||
|
- platform: qq-obv11
|
||||||
|
accountId: "123456789"
|
||||||
|
- platform: weixin-ilink
|
||||||
|
accountId: wx_owner_abc
|
||||||
|
notes: "主人"
|
||||||
|
`)
|
||||||
|
p, err := LoadOwnerProfile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadOwnerProfile error: %v", err)
|
||||||
|
}
|
||||||
|
if p.ID != "owner" || p.DisplayName != "叶酱" || p.DefaultPersona != "cyrene" {
|
||||||
|
t.Fatalf("unexpected profile: %+v", p)
|
||||||
|
}
|
||||||
|
if !p.IsOwner("qq-obv11", "123456789") {
|
||||||
|
t.Error("expected (qq-obv11,123456789) to be owner")
|
||||||
|
}
|
||||||
|
if !p.IsOwner("weixin-ilink", "wx_owner_abc") {
|
||||||
|
t.Error("expected (weixin-ilink,wx_owner_abc) to be owner")
|
||||||
|
}
|
||||||
|
if p.IsOwner("qq-obv11", "999") {
|
||||||
|
t.Error("non-owner account should not match")
|
||||||
|
}
|
||||||
|
if p.IsOwner("telegram", "123456789") {
|
||||||
|
t.Error("wrong platform must not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadOwnerProfile_Validation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
substr string
|
||||||
|
}{
|
||||||
|
{name: "missing id", content: "displayName: 叶酱\naccounts:\n - platform: qq\n accountId: '1'\n", substr: "id"},
|
||||||
|
{name: "missing displayName", content: "id: owner\naccounts:\n - platform: qq\n accountId: '1'\n", substr: "displayName"},
|
||||||
|
{name: "account missing platform", content: "id: owner\ndisplayName: 叶酱\naccounts:\n - accountId: '1'\n", substr: "platform"},
|
||||||
|
{name: "account missing id", content: "id: owner\ndisplayName: 叶酱\naccounts:\n - platform: qq\n", substr: "accountId"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
path := writeProfile(t, tt.content)
|
||||||
|
_, err := LoadOwnerProfile(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected validation error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrRequiredField) {
|
||||||
|
t.Fatalf("expected ErrRequiredField, got: %v", err)
|
||||||
|
}
|
||||||
|
if len(tt.substr) > 0 && !contains(err.Error(), tt.substr) {
|
||||||
|
t.Fatalf("error %q should mention %q", err.Error(), tt.substr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadOwnerProfile_EmptyWhitelistDefaultsDeny(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := writeProfile(t, "id: owner\ndisplayName: 叶酱\naccounts: []\n")
|
||||||
|
p, err := LoadOwnerProfile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadOwnerProfile error: %v", err)
|
||||||
|
}
|
||||||
|
if p.IsOwner("qq-obv11", "123") {
|
||||||
|
t.Error("empty whitelist must default to deny")
|
||||||
|
}
|
||||||
|
if len(p.AccountIdentifiers()) != 0 {
|
||||||
|
t.Errorf("expected no account identifiers, got %v", p.AccountIdentifiers())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnerProfile_AccountIdentifiers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
p := &OwnerProfile{
|
||||||
|
Accounts: []OwnerAccount{
|
||||||
|
{Platform: "qq", AccountID: "111"},
|
||||||
|
{Platform: "wx", AccountID: "222"},
|
||||||
|
{Platform: "qq", AccountID: "111"}, // duplicate
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ids := p.AccountIdentifiers()
|
||||||
|
if len(ids) != 2 {
|
||||||
|
t.Fatalf("expected 2 unique identifiers, got %d: %v", len(ids), ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
return len(sub) > 0 && len(s) >= len(sub) && (func() bool {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})()
|
||||||
|
}
|
||||||
Reference in new issue
Block a user