feat(user): 主人档案/白名单(OwnerProfile+loader)+ 给 LLM 的身份说明书(owner_identity.md)

This commit is contained in:
Aska committed 2026-09-06 04:55:18 +08:00
1 parent c647d33a00
commit b9253a4980
5 files changed
+284

No files matched your search

+59
View File
@@ -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
}
+54
View File
@@ -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
}
+126
View File
@@ -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
})()
}