Compare commits

...

4 Commits

23 changed files with 2088 additions and 49 deletions
+3
View File
@@ -1,6 +1,9 @@
# DobotAI configuration
DOBOTAI_LISTEN_ADDR=127.0.0.1:8090
DOBOTAI_INGRESS_TOKEN=replace-me
# Optional local-only audit API. Use a distinct secret from DOBOTAI_INGRESS_TOKEN.
# When set, DOBOTAI_TENANT_KEY must be set and the listen address must be loopback-only.
DOBOTAI_ADMIN_TOKEN=
DOBOTAI_GROUP_SESSION_POLICY=shared
DOBOTAI_AGENT_BINDINGS={"dingtalk:*":"replace-with-agent-id"}
+32 -5
View File
@@ -12,6 +12,7 @@ import (
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/agentbinding"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/httpapi"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/qa"
@@ -49,7 +50,15 @@ func main() {
if err != nil {
log.Fatal(err)
}
api, err := httpapi.New(qaService, os.Getenv("DOBOTAI_INGRESS_TOKEN"))
address := strings.TrimSpace(os.Getenv("DOBOTAI_LISTEN_ADDR"))
if address == "" {
address = "127.0.0.1:8090"
}
auditOptions, err := buildAuditOptions(store, os.Getenv("DOBOTAI_TENANT_KEY"), os.Getenv("DOBOTAI_ADMIN_TOKEN"), address)
if err != nil {
log.Fatal(err)
}
api, err := httpapi.NewWithOptions(qaService, os.Getenv("DOBOTAI_INGRESS_TOKEN"), auditOptions)
if err != nil {
log.Fatal(err)
}
@@ -99,10 +108,6 @@ func main() {
defer streamClient.Close()
log.Printf("DingTalk Stream ingress started for tenant %s", dingCfg.TenantKey)
}
address := strings.TrimSpace(os.Getenv("DOBOTAI_LISTEN_ADDR"))
if address == "" {
address = "127.0.0.1:8090"
}
server := &http.Server{Addr: address, Handler: api.Handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 130 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
log.Printf("DobotAI ingress listening on %s", address)
@@ -118,3 +123,25 @@ func main() {
log.Printf("HTTP shutdown: %v", err)
}
}
func buildAuditOptions(repo audit.TurnRepository, tenantKey, adminToken, address string) (httpapi.Options, error) {
adminToken = strings.TrimSpace(adminToken)
if adminToken == "" {
return httpapi.Options{}, nil
}
if repo == nil {
return httpapi.Options{}, errors.New("audit repository is required when DOBOTAI_ADMIN_TOKEN is set")
}
tenantKey = strings.TrimSpace(tenantKey)
if tenantKey == "" {
return httpapi.Options{}, errors.New("DOBOTAI_TENANT_KEY is required when DOBOTAI_ADMIN_TOKEN is set")
}
if err := httpapi.ValidateLoopbackListenAddress(address); err != nil {
return httpapi.Options{}, err
}
service, err := audit.NewService(tenantKey, repo)
if err != nil {
return httpapi.Options{}, err
}
return httpapi.Options{AuditService: service, AdminToken: adminToken}, nil
}
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"context"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
)
type auditRepoStub struct {
tenant string
}
func (r *auditRepoStub) ListTurns(_ context.Context, tenant string, _ audit.ListQuery) ([]audit.TurnSummary, error) {
r.tenant = tenant
return nil, nil
}
func (r *auditRepoStub) GetTurn(context.Context, string, string) (audit.TurnDetail, error) {
return audit.TurnDetail{}, audit.ErrNotFound
}
func TestBuildAuditOptionsDisabledIsBackwardCompatible(t *testing.T) {
opts, err := buildAuditOptions(nil, "", "", "0.0.0.0:8090")
if err != nil {
t.Fatal(err)
}
if opts.AuditService != nil || opts.AdminToken != "" {
t.Fatalf("options=%+v", opts)
}
}
func TestBuildAuditOptionsRequiresTenantRepositoryAndLoopback(t *testing.T) {
repo := &auditRepoStub{}
for _, tc := range []struct {
name string
repo audit.TurnRepository
tenant string
address string
}{
{name: "missing repository", tenant: "tenant", address: "127.0.0.1:8090"},
{name: "missing tenant", repo: repo, address: "127.0.0.1:8090"},
{name: "public listener", repo: repo, tenant: "tenant", address: "0.0.0.0:8090"},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := buildAuditOptions(tc.repo, tc.tenant, "admin-secret", tc.address); err == nil {
t.Fatal("expected configuration error")
}
})
}
}
func TestBuildAuditOptionsFixesServerSideTenant(t *testing.T) {
repo := &auditRepoStub{}
opts, err := buildAuditOptions(repo, " tenant-a ", " admin-secret ", "127.0.0.1:8090")
if err != nil {
t.Fatal(err)
}
if opts.AdminToken != "admin-secret" || opts.AuditService == nil {
t.Fatalf("options=%+v", opts)
}
if _, err := opts.AuditService.ListTurns(context.Background(), audit.ListTurnsRequest{}); err != nil {
t.Fatal(err)
}
if repo.tenant != "tenant-a" {
t.Fatalf("tenant=%q", repo.tenant)
}
}
@@ -0,0 +1,16 @@
-- 000004_delivery_audit down migration
-- Drop in reverse order: index first, then columns.
DROP INDEX IF EXISTS idx_messages_tenant_status_updated;
ALTER TABLE messages
DROP COLUMN IF EXISTS failure_code;
ALTER TABLE messages
DROP COLUMN IF EXISTS provider_message_id;
ALTER TABLE messages
DROP COLUMN IF EXISTS delivery_method;
ALTER TABLE messages
DROP COLUMN IF EXISTS delivered_at;
@@ -0,0 +1,20 @@
-- 000004_delivery_audit: delivery audit columns on messages
-- Adds delivered_at, delivery_method, provider_message_id, failure_code
-- with a tenant+status+updated_at query index.
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS delivered_at TIMESTAMPTZ;
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS delivery_method TEXT NOT NULL DEFAULT ''
CHECK (delivery_method IN ('', 'card', 'markdown'));
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS provider_message_id TEXT NOT NULL DEFAULT '';
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS failure_code TEXT NOT NULL DEFAULT ''
CHECK (failure_code IN ('', 'channel_delivery_failed'));
CREATE INDEX IF NOT EXISTS idx_messages_tenant_status_updated
ON messages (tenant_key, status, updated_at DESC);
@@ -0,0 +1,200 @@
// Package migrations_test contains source-level contract tests for the
// 000004_delivery_audit migration. No real database connection is used.
package migrations_test
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
func read004UpMigration(t *testing.T) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000004_delivery_audit.up.sql"))
if err != nil {
t.Fatalf("read up migration: %v", err)
}
return string(data)
}
func read004DownMigration(t *testing.T) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000004_delivery_audit.down.sql"))
if err != nil {
t.Fatalf("read down migration: %v", err)
}
return string(data)
}
// TestUp004UsesIfNotExists ensures ALTER TABLE uses IF NOT EXISTS for new columns.
func TestUp004UsesIfNotExists(t *testing.T) {
up := read004UpMigration(t)
upper := strings.ToUpper(up)
if !strings.Contains(upper, "ADD COLUMN IF NOT EXISTS") {
t.Error("up migration must use ADD COLUMN IF NOT EXISTS for idempotency")
}
if !strings.Contains(upper, "CREATE INDEX IF NOT EXISTS") {
t.Error("up migration must use CREATE INDEX IF NOT EXISTS for idempotency")
}
// At least 4 new columns: delivered_at, delivery_method, provider_message_id, failure_code
if got := countMatches(up, `(?i)ADD\s+COLUMN\s+IF\s+NOT\s+EXISTS`); got < 4 {
t.Errorf("expected at least 4 ADD COLUMN IF NOT EXISTS, got %d", got)
}
// At least 1 new index
if got := countMatches(up, `(?i)CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS`); got < 1 {
t.Errorf("expected at least 1 CREATE INDEX IF NOT EXISTS, got %d", got)
}
}
// TestUp004AddsDeliveryColumns ensures all 4 delivery audit columns are added to messages.
func TestUp004AddsDeliveryColumns(t *testing.T) {
up := read004UpMigration(t)
upper := strings.ToUpper(up)
columns := []string{
"DELIVERED_AT TIMESTAMPTZ",
"DELIVERY_METHOD TEXT NOT NULL DEFAULT ''",
"PROVIDER_MESSAGE_ID TEXT NOT NULL DEFAULT ''",
"FAILURE_CODE TEXT NOT NULL DEFAULT ''",
}
for _, col := range columns {
if !strings.Contains(upper, col) {
t.Errorf("up migration must add column %q", col)
}
}
}
// TestUp004DeliveryMethodCheck ensures delivery_method CHECK constraint.
func TestUp004DeliveryMethodCheck(t *testing.T) {
up := read004UpMigration(t)
upper := strings.ToUpper(up)
// delivery_method must have CHECK constraint
if !strings.Contains(upper, "DELIVERY_METHOD") {
t.Fatal("up migration must add delivery_method column")
}
// CHECK (delivery_method IN ('', 'card', 'markdown'))
if !strings.Contains(upper, "CARD") || !strings.Contains(upper, "MARKDOWN") {
t.Error("delivery_method CHECK must include 'card' and 'markdown'")
}
}
func TestUp004FailureCodeCheck(t *testing.T) {
up := strings.ToUpper(read004UpMigration(t))
if !strings.Contains(up, "CHECK (FAILURE_CODE IN ('', 'CHANNEL_DELIVERY_FAILED'))") {
t.Error("failure_code must be constrained to safe enumerated values")
}
}
// TestUp004AddsTenantStatusUpdatedAtIndex ensures the query index is created.
func TestUp004AddsTenantStatusUpdatedAtIndex(t *testing.T) {
up := read004UpMigration(t)
upper := strings.ToUpper(up)
// Must have an index on (tenant_key, status, updated_at DESC)
hasDesired := strings.Contains(upper, "TENANT_KEY") &&
strings.Contains(upper, "STATUS") &&
strings.Contains(upper, "UPDATED_AT") &&
strings.Contains(upper, "DESC")
if !hasDesired {
t.Error("up migration must create index on (tenant_key, status, updated_at DESC)")
}
}
// TestUp004NoForbiddenColumns ensures no secrets/protocol columns are introduced.
func TestUp004NoForbiddenColumns(t *testing.T) {
up := read004UpMigration(t)
upper := strings.ToUpper(up)
forbidden := []string{
"RAW_PAYLOAD", "SESSIONWEBHOOK", "DOWNLOAD_CODE",
"DATAURI", "DATA_URI", "TOKEN", "RAW_RESPONSE", "RAW_ERROR",
"PROVIDER_RESPONSE", "API_RESPONSE",
}
for _, col := range forbidden {
if strings.Contains(upper, col) {
t.Errorf("migration must not contain forbidden column %q", col)
}
}
}
// TestUp004DoesNotModifyOtherTables ensures migration only touches messages.
func TestUp004DoesNotModifyOtherTables(t *testing.T) {
up := read004UpMigration(t)
upper := strings.ToUpper(up)
// Must not create/drop turns, session_mappings, channel_actors, channel_conversations
if strings.Contains(upper, "CREATE TABLE") {
t.Error("migration 004 must not CREATE TABLE")
}
if strings.Contains(upper, "DROP TABLE") {
t.Error("migration 004 must not DROP TABLE")
}
if strings.Contains(upper, "ALTER TABLE TURNS") {
t.Error("migration 004 must not ALTER TABLE turns")
}
if strings.Contains(upper, "ALTER TABLE SESSION_MAPPINGS") {
t.Error("migration 004 must not ALTER TABLE session_mappings")
}
if strings.Contains(upper, "ALTER TABLE CHANNEL_ACTORS") {
t.Error("migration 004 must not ALTER TABLE channel_actors")
}
if strings.Contains(upper, "ALTER TABLE CHANNEL_CONVERSATIONS") {
t.Error("migration 004 must not ALTER TABLE channel_conversations")
}
}
// TestDown004DropsDeliveryColumns ensures down drops columns in reverse order.
func TestDown004DropsDeliveryColumns(t *testing.T) {
down := read004DownMigration(t)
upper := strings.ToUpper(down)
columns := []string{
"FAILURE_CODE",
"PROVIDER_MESSAGE_ID",
"DELIVERY_METHOD",
"DELIVERED_AT",
}
for _, col := range columns {
if !strings.Contains(upper, "DROP COLUMN IF EXISTS "+col) {
t.Errorf("down migration must drop %s with IF EXISTS", col)
}
}
// Must drop the index too
if !strings.Contains(upper, "DROP INDEX IF EXISTS") {
t.Error("down migration must drop the delivery audit index")
}
}
// TestDown004DoesNotDropBaselineTable ensures down does not drop messages table.
func TestDown004DoesNotDropBaselineTable(t *testing.T) {
down := read004DownMigration(t)
upper := strings.ToUpper(down)
if strings.Contains(upper, "DROP TABLE IF EXISTS MESSAGES") {
t.Error("down migration must not drop the baseline messages table")
}
}
// TestUp004MessagesNoReservedWords ensures no bare reserved words in new columns.
func TestUp004MessagesNoReservedWords(t *testing.T) {
up := read004UpMigration(t)
// Check for bare "references" as column name
bareRefs := regexp.MustCompile(`(?im)^\s*REFERENCES\s+`)
if bareRefs.MatchString(up) {
lines := strings.Split(up, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
upperLine := strings.ToUpper(trimmed)
if strings.HasPrefix(upperLine, "REFERENCES ") && !strings.Contains(upperLine, "FOREIGN KEY") {
t.Errorf("reserved word 'references' used as column name: %s", trimmed)
}
}
}
}
+124
View File
@@ -0,0 +1,124 @@
package audit
import (
"context"
"errors"
"time"
)
var (
ErrNotFound = errors.New("turn not found")
ErrInvalidArgument = errors.New("invalid audit query")
)
// TurnStatus represents the status of a turn.
type TurnStatus string
const (
StatusProcessing TurnStatus = "processing"
StatusSucceeded TurnStatus = "succeeded"
StatusFailed TurnStatus = "failed"
StatusCancelled TurnStatus = "cancelled"
)
// TurnSummary is a lightweight representation of a turn for list views.
// It must never contain full question/answer text, error_message, WeKnora
// session/request IDs, or provider_message_id.
type TurnSummary struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Channel string `json:"channel"`
Status TurnStatus `json:"status"`
ConversationType string `json:"conversation_type"`
ConversationTitle string `json:"conversation_title,omitempty"`
ActorExternalID string `json:"actor_external_id,omitempty"`
ActorDisplayName string `json:"actor_display_name,omitempty"`
QuestionSummary string `json:"question_summary"`
AnswerSummary string `json:"answer_summary"`
ReferenceCount int `json:"reference_count"`
AnswerDeliveryStatus string `json:"answer_delivery_status,omitempty"`
AnswerDeliveryMethod string `json:"answer_delivery_method,omitempty"`
}
// SafeReference is a reference with only safe fields exposed.
// Content and Raw are never included.
type SafeReference struct {
ID string `json:"id"`
KnowledgeID string `json:"knowledge_id"`
KnowledgeTitle string `json:"knowledge_title"`
Score float64 `json:"score"`
}
// SafeMedia is a media audit entry with only safe metadata.
type SafeMedia struct {
Type string `json:"type"`
MIMEType string `json:"mime_type"`
SizeBytes int64 `json:"size_bytes"`
Width int `json:"width"`
Height int `json:"height"`
}
// MessageMeta provides request/answer message metadata.
type MessageMeta struct {
ID string `json:"id"`
Direction string `json:"direction"`
Role string `json:"role"`
MessageType string `json:"message_type"`
Status string `json:"status"`
ActorName string `json:"actor_name,omitempty"`
CreatedAt time.Time `json:"created_at"`
DeliveredAt *time.Time `json:"delivered_at,omitempty"`
DeliveryMethod string `json:"delivery_method,omitempty"`
FailureCode string `json:"failure_code,omitempty"`
}
// TurnDetail is the full detail view of a turn. It must never include
// error_message, WeKnora session/request ID, provider_message_id,
// sessionWebhook, tokens, raw API/error, or Reference.Content/Raw.
type TurnDetail struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Channel string `json:"channel"`
Status TurnStatus `json:"status"`
ConversationType string `json:"conversation_type"`
ConversationTitle string `json:"conversation_title,omitempty"`
ActorExternalID string `json:"actor_external_id,omitempty"`
ActorDisplayName string `json:"actor_display_name,omitempty"`
Question string `json:"question"`
Answer string `json:"answer"`
References []SafeReference `json:"references,omitempty"`
Media []SafeMedia `json:"media,omitempty"`
RequestMessage *MessageMeta `json:"request_message,omitempty"`
AnswerMessage *MessageMeta `json:"answer_message,omitempty"`
ReferenceCount int `json:"reference_count"`
AnswerDeliveryStatus string `json:"answer_delivery_status,omitempty"`
AnswerDeliveryMethod string `json:"answer_delivery_method,omitempty"`
}
// ListTurnsRequest is the input for listing turns.
type ListTurnsRequest struct {
Limit int
Cursor string
Channel string
Status TurnStatus
}
// ListTurnsResult is the output of a list turns query.
type ListTurnsResult struct {
Items []TurnSummary `json:"items"`
NextCursor string `json:"next_cursor,omitempty"`
}
type ListQuery struct {
Limit int
Channel string
Status TurnStatus
BeforeCreatedAt *time.Time
BeforeID string
}
// TurnRepository is the data access interface for audit queries.
type TurnRepository interface {
ListTurns(ctx context.Context, tenantKey string, query ListQuery) ([]TurnSummary, error)
GetTurn(ctx context.Context, tenantKey, turnID string) (TurnDetail, error)
}
+54
View File
@@ -0,0 +1,54 @@
package audit
import (
"encoding/base64"
"encoding/json"
"errors"
"strings"
"time"
)
const maxCursorLength = 512
type cursorData struct {
CreatedAt time.Time `json:"t"`
ID string `json:"id"`
}
func encodeCursor(createdAt time.Time, id string) string {
data := cursorData{
CreatedAt: createdAt,
ID: id,
}
payload, err := json.Marshal(data)
if err != nil {
// This should never happen for a simple struct.
panic("encodeCursor marshal: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(payload)
}
func encodeCursorString(payload string) string {
return base64.RawURLEncoding.EncodeToString([]byte(payload))
}
func decodeCursor(cursor string) (cursorData, error) {
if strings.TrimSpace(cursor) == "" {
return cursorData{}, errors.New("empty cursor")
}
if len(cursor) > maxCursorLength {
return cursorData{}, errors.New("cursor too long")
}
payload, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return cursorData{}, errors.New("invalid cursor encoding")
}
var data cursorData
if err := json.Unmarshal(payload, &data); err != nil {
return cursorData{}, errors.New("invalid cursor payload")
}
if data.CreatedAt.IsZero() || data.ID == "" {
return cursorData{}, errors.New("incomplete cursor data")
}
return data, nil
}
+63
View File
@@ -0,0 +1,63 @@
package audit
import (
"strings"
"testing"
"time"
)
func TestCursorRoundTrip(t *testing.T) {
now := time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)
cursor := encodeCursor(now, "turn-abc-123")
decoded, err := decodeCursor(cursor)
if err != nil {
t.Fatalf("decodeCursor: %v", err)
}
if !decoded.CreatedAt.Equal(now) {
t.Fatalf("CreatedAt=%v, want %v", decoded.CreatedAt, now)
}
if decoded.ID != "turn-abc-123" {
t.Fatalf("ID=%q, want %q", decoded.ID, "turn-abc-123")
}
}
func TestDecodeCursorRejectsEmpty(t *testing.T) {
_, err := decodeCursor("")
if err == nil {
t.Fatal("expected error for empty cursor")
}
}
func TestDecodeCursorRejectsInvalidBase64(t *testing.T) {
_, err := decodeCursor("!!!not-valid-base64!!!")
if err == nil {
t.Fatal("expected error for invalid base64")
}
}
func TestDecodeCursorRejectsTruncated(t *testing.T) {
// Create a valid cursor then truncate it
cursor := encodeCursor(time.Now().UTC(), "id")
truncated := cursor[:len(cursor)-3]
_, err := decodeCursor(truncated)
if err == nil {
t.Fatal("expected error for truncated cursor")
}
}
func TestDecodeCursorRejectsTooLong(t *testing.T) {
// 1024 bytes should exceed max cursor length
_, err := decodeCursor(strings.Repeat("A", 1024))
if err == nil {
t.Fatal("expected error for too-long cursor")
}
}
func TestDecodeCursorRejectsMissingFields(t *testing.T) {
// Invalid JSON payload (not a valid cursor struct)
// We create a cursor-like string that decodes but has missing fields
_, err := decodeCursor(encodeCursorString("{}"))
if err == nil {
t.Fatal("expected error for missing fields in cursor")
}
}
+97
View File
@@ -0,0 +1,97 @@
package audit
import (
"context"
"fmt"
"strings"
)
const (
defaultLimit = 20
maxLimit = 100
maxChannelLen = 128
maxTurnIDLen = 128
maxSummaryLen = 200
)
type Service struct {
tenantKey string
repo TurnRepository
}
func NewService(tenantKey string, repo TurnRepository) (*Service, error) {
tenantKey = strings.TrimSpace(tenantKey)
if tenantKey == "" || repo == nil {
return nil, fmt.Errorf("%w: tenant and repository are required", ErrInvalidArgument)
}
return &Service{tenantKey: tenantKey, repo: repo}, nil
}
func (s *Service) ListTurns(ctx context.Context, req ListTurnsRequest) (ListTurnsResult, error) {
if req.Limit == 0 {
req.Limit = defaultLimit
}
if req.Limit < 1 || req.Limit > maxLimit {
return ListTurnsResult{}, fmt.Errorf("%w: limit must be between 1 and %d", ErrInvalidArgument, maxLimit)
}
req.Channel = strings.TrimSpace(req.Channel)
if len(req.Channel) > maxChannelLen {
return ListTurnsResult{}, fmt.Errorf("%w: channel too long", ErrInvalidArgument)
}
if !validStatus(req.Status) {
return ListTurnsResult{}, fmt.Errorf("%w: invalid status", ErrInvalidArgument)
}
query := ListQuery{Limit: req.Limit + 1, Channel: req.Channel, Status: req.Status}
if req.Cursor != "" {
cursor, err := decodeCursor(req.Cursor)
if err != nil {
return ListTurnsResult{}, fmt.Errorf("%w: invalid cursor", ErrInvalidArgument)
}
query.BeforeCreatedAt = &cursor.CreatedAt
query.BeforeID = cursor.ID
}
items, err := s.repo.ListTurns(ctx, s.tenantKey, query)
if err != nil {
return ListTurnsResult{}, err
}
for i := range items {
items[i].QuestionSummary = truncateRunes(items[i].QuestionSummary, maxSummaryLen)
items[i].AnswerSummary = truncateRunes(items[i].AnswerSummary, maxSummaryLen)
}
result := ListTurnsResult{Items: items}
if result.Items == nil {
result.Items = []TurnSummary{}
}
if len(result.Items) > req.Limit {
result.Items = result.Items[:req.Limit]
last := result.Items[len(result.Items)-1]
result.NextCursor = encodeCursor(last.CreatedAt, last.ID)
}
return result, nil
}
func (s *Service) GetTurn(ctx context.Context, turnID string) (TurnDetail, error) {
turnID = strings.TrimSpace(turnID)
if turnID == "" || len(turnID) > maxTurnIDLen {
return TurnDetail{}, fmt.Errorf("%w: invalid turn id", ErrInvalidArgument)
}
return s.repo.GetTurn(ctx, s.tenantKey, turnID)
}
func validStatus(status TurnStatus) bool {
switch status {
case "", StatusProcessing, StatusSucceeded, StatusFailed, StatusCancelled:
return true
default:
return false
}
}
func truncateRunes(value string, limit int) string {
runes := []rune(value)
if len(runes) <= limit {
return value
}
return string(runes[:limit])
}
+179
View File
@@ -0,0 +1,179 @@
package audit
import (
"context"
"errors"
"strings"
"testing"
"time"
)
type stubRepo struct {
listFn func(context.Context, string, ListQuery) ([]TurnSummary, error)
getFn func(context.Context, string, string) (TurnDetail, error)
}
func (r *stubRepo) ListTurns(ctx context.Context, tenant string, query ListQuery) ([]TurnSummary, error) {
if r.listFn == nil {
return nil, nil
}
return r.listFn(ctx, tenant, query)
}
func (r *stubRepo) GetTurn(ctx context.Context, tenant, id string) (TurnDetail, error) {
if r.getFn == nil {
return TurnDetail{}, ErrNotFound
}
return r.getFn(ctx, tenant, id)
}
func TestNewServiceRequiresTenantAndRepository(t *testing.T) {
repo := &stubRepo{}
if _, err := NewService("", repo); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("empty tenant error=%v", err)
}
if _, err := NewService("tenant", nil); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("nil repository error=%v", err)
}
}
func TestServiceListUsesServerTenantDefaultLimitAndFetchesExtra(t *testing.T) {
var gotTenant string
var gotQuery ListQuery
repo := &stubRepo{listFn: func(_ context.Context, tenant string, query ListQuery) ([]TurnSummary, error) {
gotTenant, gotQuery = tenant, query
return []TurnSummary{}, nil
}}
svc, err := NewService(" tenant-a ", repo)
if err != nil {
t.Fatal(err)
}
page, err := svc.ListTurns(context.Background(), ListTurnsRequest{})
if err != nil {
t.Fatal(err)
}
if gotTenant != "tenant-a" || gotQuery.Limit != 21 {
t.Fatalf("tenant=%q query=%+v", gotTenant, gotQuery)
}
if page.Items == nil || page.NextCursor != "" {
t.Fatalf("page=%+v", page)
}
}
func TestServiceListBuildsStableNextCursor(t *testing.T) {
t3 := time.Date(2026, 7, 21, 3, 0, 0, 0, time.UTC)
t2 := t3.Add(-time.Minute)
t1 := t2.Add(-time.Minute)
repo := &stubRepo{listFn: func(_ context.Context, _ string, query ListQuery) ([]TurnSummary, error) {
if query.Limit != 3 {
t.Fatalf("repository limit=%d want=3", query.Limit)
}
return []TurnSummary{{ID: "turn-3", CreatedAt: t3}, {ID: "turn-2", CreatedAt: t2}, {ID: "turn-1", CreatedAt: t1}}, nil
}}
svc, _ := NewService("tenant", repo)
page, err := svc.ListTurns(context.Background(), ListTurnsRequest{Limit: 2})
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 2 || page.Items[1].ID != "turn-2" || page.NextCursor == "" {
t.Fatalf("page=%+v", page)
}
decoded, err := decodeCursor(page.NextCursor)
if err != nil || decoded.ID != "turn-2" || !decoded.CreatedAt.Equal(t2) {
t.Fatalf("next cursor=%+v err=%v", decoded, err)
}
}
func TestServiceListDecodesCursorForRepository(t *testing.T) {
created := time.Date(2026, 7, 21, 2, 0, 0, 123, time.UTC)
cursor := encodeCursor(created, "turn-before")
repo := &stubRepo{listFn: func(_ context.Context, _ string, query ListQuery) ([]TurnSummary, error) {
if query.BeforeID != "turn-before" || query.BeforeCreatedAt == nil || !query.BeforeCreatedAt.Equal(created) {
t.Fatalf("decoded query=%+v", query)
}
return nil, nil
}}
svc, _ := NewService("tenant", repo)
if _, err := svc.ListTurns(context.Background(), ListTurnsRequest{Cursor: cursor}); err != nil {
t.Fatal(err)
}
}
func TestServiceListValidation(t *testing.T) {
svc, _ := NewService("tenant", &stubRepo{})
for _, tc := range []struct {
name string
req ListTurnsRequest
}{
{name: "negative limit", req: ListTurnsRequest{Limit: -1}},
{name: "too large limit", req: ListTurnsRequest{Limit: 101}},
{name: "bad status", req: ListTurnsRequest{Status: "bogus"}},
{name: "long channel", req: ListTurnsRequest{Channel: strings.Repeat("x", 129)}},
{name: "bad cursor", req: ListTurnsRequest{Cursor: "not-a-cursor"}},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := svc.ListTurns(context.Background(), tc.req); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("error=%v", err)
}
})
}
}
func TestServiceListAcceptsMaximumLimitAndFilters(t *testing.T) {
repo := &stubRepo{listFn: func(_ context.Context, _ string, query ListQuery) ([]TurnSummary, error) {
if query.Limit != 101 || query.Channel != "dingtalk" || query.Status != StatusSucceeded {
t.Fatalf("query=%+v", query)
}
return nil, nil
}}
svc, _ := NewService("tenant", repo)
if _, err := svc.ListTurns(context.Background(), ListTurnsRequest{Limit: 100, Channel: " dingtalk ", Status: StatusSucceeded}); err != nil {
t.Fatal(err)
}
}
func TestServiceListTruncatesUnicodeSummariesTo200Runes(t *testing.T) {
long := strings.Repeat("初", 250)
repo := &stubRepo{listFn: func(context.Context, string, ListQuery) ([]TurnSummary, error) {
return []TurnSummary{{ID: "turn", CreatedAt: time.Now(), QuestionSummary: long, AnswerSummary: long}}, nil
}}
svc, _ := NewService("tenant", repo)
page, err := svc.ListTurns(context.Background(), ListTurnsRequest{})
if err != nil {
t.Fatal(err)
}
if len([]rune(page.Items[0].QuestionSummary)) != 200 || len([]rune(page.Items[0].AnswerSummary)) != 200 {
t.Fatalf("summary lengths=%d/%d", len([]rune(page.Items[0].QuestionSummary)), len([]rune(page.Items[0].AnswerSummary)))
}
}
func TestServiceGetUsesServerTenant(t *testing.T) {
var gotTenant, gotID string
repo := &stubRepo{getFn: func(_ context.Context, tenant, id string) (TurnDetail, error) {
gotTenant, gotID = tenant, id
return TurnDetail{ID: id}, nil
}}
svc, _ := NewService("tenant-a", repo)
if _, err := svc.GetTurn(context.Background(), " turn-1 "); err != nil {
t.Fatal(err)
}
if gotTenant != "tenant-a" || gotID != "turn-1" {
t.Fatalf("tenant=%q id=%q", gotTenant, gotID)
}
}
func TestServiceGetValidationAndNotFound(t *testing.T) {
repo := &stubRepo{getFn: func(context.Context, string, string) (TurnDetail, error) {
return TurnDetail{}, ErrNotFound
}}
svc, _ := NewService("tenant", repo)
if _, err := svc.GetTurn(context.Background(), " "); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("blank id error=%v", err)
}
if _, err := svc.GetTurn(context.Background(), strings.Repeat("x", 129)); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("long id error=%v", err)
}
if _, err := svc.GetTurn(context.Background(), "missing"); !errors.Is(err, ErrNotFound) {
t.Fatalf("missing error=%v", err)
}
}
+80 -10
View File
@@ -49,6 +49,10 @@ type QAAsker interface {
Ask(ctx context.Context, req core.QARequest) (core.QAResult, error)
}
type DeliveryRecorder interface {
RecordDelivery(ctx context.Context, update core.DeliveryUpdate) error
}
// StreamingQAAsker asks the QA backend with streaming observer support.
type StreamingQAAsker interface {
QAAsker
@@ -257,6 +261,9 @@ func (h *Handler) handleNonStreaming(ctx context.Context, webhookURL string, req
}
return nil
}
if result.Replayed && result.DeliveryStatus == core.DeliverySent {
return nil
}
if h.card != nil {
cardReq := CardRequest{
@@ -265,12 +272,18 @@ func (h *Handler) handleNonStreaming(ctx context.Context, webhookURL string, req
ExternalChatID: req.ExternalChatID,
Content: result.Answer,
}
if _, cardErr := h.card.SendFinalCard(ctx, cardReq); cardErr == nil {
if outTrackID, cardErr := h.card.SendFinalCard(ctx, cardReq); cardErr == nil {
h.recordDelivery(ctx, result, core.DeliverySent, core.DeliveryMethodCard, outTrackID, "")
return nil
}
log.Printf("DingTalk card delivery failed, falling back to markdown")
}
return h.sendReply(ctx, webhookURL, result.Answer)
if err := h.sendReply(ctx, webhookURL, result.Answer); err != nil {
h.recordDelivery(ctx, result, core.DeliveryFailed, "", "", core.DeliveryFailureChannelDelivery)
return err
}
h.recordDelivery(ctx, result, core.DeliverySent, core.DeliveryMethodMarkdown, "", "")
return nil
}
func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req core.QARequest, streamingCard StreamingCardSender, streamingQA StreamingQAAsker) error {
@@ -291,7 +304,15 @@ func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req co
}
return nil
}
return h.sendReply(ctx, webhookURL, result.Answer)
if result.Replayed && result.DeliveryStatus == core.DeliverySent {
return nil
}
if replyErr := h.sendReply(ctx, webhookURL, result.Answer); replyErr != nil {
h.recordDelivery(ctx, result, core.DeliveryFailed, "", "", core.DeliveryFailureChannelDelivery)
return replyErr
}
h.recordDelivery(ctx, result, core.DeliverySent, core.DeliveryMethodMarkdown, "", "")
return nil
}
// Streaming observer with the configured update throttle (500ms by default).
@@ -314,9 +335,9 @@ func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req co
result, err := streamingQA.AskStream(ctx, req, observer)
if err != nil || strings.TrimSpace(result.Answer) == "" {
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, qaErrorReply, true)
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, core.QAResult{}, qaErrorReply, true)
}
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, result.Answer, false)
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, result, result.Answer, false)
}
func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, req core.QARequest, streamingCard StreamingCardSender, eventQA EventQAAsker) error {
@@ -333,7 +354,15 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
if askErr != nil {
return h.sendReply(ctx, webhookURL, qaErrorReply)
}
return h.sendReply(ctx, webhookURL, result.Answer)
if result.Replayed && result.DeliveryStatus == core.DeliverySent {
return nil
}
if replyErr := h.sendReply(ctx, webhookURL, result.Answer); replyErr != nil {
h.recordDelivery(ctx, result, core.DeliveryFailed, "", "", core.DeliveryFailureChannelDelivery)
return replyErr
}
h.recordDelivery(ctx, result, core.DeliverySent, core.DeliveryMethodMarkdown, "", "")
return nil
}
pump := newCardUpdatePump(ctx, h.cardUpdateInterval, func(updateCtx context.Context, content string) error {
@@ -358,12 +387,12 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
pump.Stop()
if err != nil || strings.TrimSpace(result.Answer) == "" {
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, qaErrorReply, true)
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, core.QAResult{}, qaErrorReply, true)
}
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, result.Answer, false)
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, result, result.Answer, false)
}
func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string, streamingCard StreamingCardSender, outTrackID, content string, isError bool) error {
func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string, streamingCard StreamingCardSender, outTrackID string, result core.QAResult, content string, isError bool) error {
timeout := h.finalDeliveryTimeout
if timeout <= 0 {
timeout = defaultHTTPTimeout
@@ -375,6 +404,9 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
updateErr := streamingCard.UpdateCard(finalCtx, outTrackID, content, true, isError)
cancelFinal()
if updateErr == nil {
if !isError {
h.recordDelivery(ctx, result, core.DeliverySent, core.DeliveryMethodCard, outTrackID, "")
}
return nil
}
@@ -382,7 +414,45 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
// a fresh delivery window rather than reusing the expired finalCtx.
fallbackCtx, cancelFallback := context.WithTimeout(context.WithoutCancel(ctx), timeout)
defer cancelFallback()
return h.sendReply(fallbackCtx, webhookURL, content)
if err := h.sendReply(fallbackCtx, webhookURL, content); err != nil {
if !isError {
h.recordDelivery(ctx, result, core.DeliveryFailed, "", "", core.DeliveryFailureChannelDelivery)
}
return err
}
if !isError {
h.recordDelivery(ctx, result, core.DeliverySent, core.DeliveryMethodMarkdown, "", "")
}
return nil
}
func (h *Handler) recordDelivery(ctx context.Context, result core.QAResult, status, method, providerMessageID, failureCode string) {
if strings.TrimSpace(result.TurnID) == "" || strings.TrimSpace(result.AnswerMessageID) == "" {
return
}
recorder, ok := h.qa.(DeliveryRecorder)
if !ok {
return
}
timeout := h.finalDeliveryTimeout
if timeout <= 0 {
timeout = defaultHTTPTimeout
}
auditCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
defer cancel()
err := recorder.RecordDelivery(auditCtx, core.DeliveryUpdate{
TenantKey: h.cfg.TenantKey,
Channel: h.cfg.Channel,
AnswerMessageID: result.AnswerMessageID,
CorrelationID: result.TurnID,
Status: status,
DeliveryMethod: method,
ProviderMessageID: providerMessageID,
FailureCode: failureCode,
})
if err != nil {
log.Printf("DingTalk delivery audit update failed: status=%s method=%s", status, method)
}
}
func renderProgressEvent(event weknora.StreamEvent) string {
+140 -4
View File
@@ -12,10 +12,12 @@ import (
)
type fakeQA struct {
called bool
lastReq core.QARequest
result core.QAResult
err error
called bool
lastReq core.QARequest
result core.QAResult
err error
deliveries []core.DeliveryUpdate
deliveryErr error
}
func (f *fakeQA) Ask(_ context.Context, req core.QARequest) (core.QAResult, error) {
@@ -24,6 +26,11 @@ func (f *fakeQA) Ask(_ context.Context, req core.QARequest) (core.QAResult, erro
return f.result, f.err
}
func (f *fakeQA) RecordDelivery(_ context.Context, update core.DeliveryUpdate) error {
f.deliveries = append(f.deliveries, update)
return f.deliveryErr
}
type fakeReply struct {
called bool
lastURL string
@@ -829,6 +836,8 @@ type fakeStreamingQA struct {
streamCalled bool
chunks []string // observer receives cumulated content after each chunk
observerCaptured weknora.AnswerObserver
deliveries []core.DeliveryUpdate
deliveryErr error
}
func (f *fakeStreamingQA) Ask(_ context.Context, req core.QARequest) (core.QAResult, error) {
@@ -851,6 +860,11 @@ func (f *fakeStreamingQA) AskStream(_ context.Context, req core.QARequest, obser
return f.result, f.err
}
func (f *fakeStreamingQA) RecordDelivery(_ context.Context, update core.DeliveryUpdate) error {
f.deliveries = append(f.deliveries, update)
return f.deliveryErr
}
// fakeStreamingCard implements both FinalCardSender and StreamingCardSender.
type fakeStreamingCard struct {
// FinalCardSender
@@ -1660,3 +1674,125 @@ func TestHandleEventStreamingFinalTimeoutUsesFreshMarkdownContext(t *testing.T)
t.Fatalf("Markdown fallback received expired context: %v", reply.contextErr)
}
}
func deliveryQAResult() core.QAResult {
return core.QAResult{
TurnID: "turn-delivery-1", AnswerMessageID: "answer-delivery-1",
DeliveryStatus: core.DeliveryGenerated, Answer: "delivery answer",
}
}
func requireDelivery(t *testing.T, deliveries []core.DeliveryUpdate, status, method, providerID, failureCode string) {
t.Helper()
if len(deliveries) != 1 {
t.Fatalf("delivery updates=%d want=1: %+v", len(deliveries), deliveries)
}
got := deliveries[0]
if got.TenantKey != "tenant-a" || got.Channel != "dingtalk" ||
got.CorrelationID != "turn-delivery-1" || got.AnswerMessageID != "answer-delivery-1" ||
got.Status != status || got.DeliveryMethod != method ||
got.ProviderMessageID != providerID || got.FailureCode != failureCode {
t.Fatalf("unexpected delivery update: %+v", got)
}
}
func TestDeliveryAuditNonStreamingCardSuccess(t *testing.T) {
qa := &fakeQA{result: deliveryQAResult()}
h := newTestHandlerWithCard(t, qa, &fakeReply{}, &fakeCard{returnID: "track-delivery-1"})
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
requireDelivery(t, qa.deliveries, core.DeliverySent, core.DeliveryMethodCard, "track-delivery-1", "")
}
func TestDeliveryAuditNonStreamingMarkdownFallbackAndFailure(t *testing.T) {
t.Run("markdown succeeds", func(t *testing.T) {
qa := &fakeQA{result: deliveryQAResult()}
h := newTestHandlerWithCard(t, qa, &fakeReply{}, &fakeCard{err: errors.New("card failed")})
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
requireDelivery(t, qa.deliveries, core.DeliverySent, core.DeliveryMethodMarkdown, "", "")
})
t.Run("both fail", func(t *testing.T) {
qa := &fakeQA{result: deliveryQAResult()}
h := newTestHandlerWithCard(t, qa, &fakeReply{err: errors.New("markdown failed")}, &fakeCard{err: errors.New("card failed")})
if err := h.Handle(context.Background(), directTextCallback()); err == nil {
t.Fatal("expected delivery error")
}
requireDelivery(t, qa.deliveries, core.DeliveryFailed, "", "", core.DeliveryFailureChannelDelivery)
})
}
func TestDeliveryAuditFailureAfterSuccessfulSendDoesNotTriggerRedelivery(t *testing.T) {
qa := &fakeQA{result: deliveryQAResult(), deliveryErr: errors.New("database unavailable")}
reply := &fakeReply{}
card := &fakeCard{returnID: "track-delivery-1"}
h := newTestHandlerWithCard(t, qa, reply, card)
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatalf("successful channel delivery must remain successful: %v", err)
}
if !card.called || reply.called || len(qa.deliveries) != 1 {
t.Fatalf("delivery was duplicated: card=%v markdown=%v audits=%d", card.called, reply.called, len(qa.deliveries))
}
}
func TestDeliveryAuditSuppressesSentReplayBeforeNonStreamingDelivery(t *testing.T) {
result := deliveryQAResult()
result.Replayed = true
result.DeliveryStatus = core.DeliverySent
qa := &fakeQA{result: result}
reply := &fakeReply{}
card := &fakeCard{returnID: "must-not-send"}
h := newTestHandlerWithCard(t, qa, reply, card)
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
if card.called || reply.called || len(qa.deliveries) != 0 {
t.Fatalf("sent replay must have zero delivery side effects: card=%v markdown=%v audits=%d", card.called, reply.called, len(qa.deliveries))
}
}
func TestDeliveryAuditStreamingCardAndMarkdown(t *testing.T) {
t.Run("final card succeeds", func(t *testing.T) {
qa := &fakeStreamingQA{result: deliveryQAResult(), chunks: []string{"delivery answer"}}
card := &fakeStreamingCard{startID: "stream-track-1"}
h := newTestHandlerWithStreaming(t, qa, &fakeReply{}, card)
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
requireDelivery(t, qa.deliveries, core.DeliverySent, core.DeliveryMethodCard, "stream-track-1", "")
})
t.Run("final card fails markdown succeeds", func(t *testing.T) {
qa := &fakeStreamingQA{result: deliveryQAResult(), chunks: []string{"delivery answer"}}
card := &fakeStreamingCard{startID: "stream-track-2", updateErr: errors.New("update failed")}
h := newTestHandlerWithStreaming(t, qa, &fakeReply{}, card)
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
requireDelivery(t, qa.deliveries, core.DeliverySent, core.DeliveryMethodMarkdown, "", "")
})
t.Run("final card and markdown fail", func(t *testing.T) {
qa := &fakeStreamingQA{result: deliveryQAResult(), chunks: []string{"delivery answer"}}
card := &fakeStreamingCard{startID: "stream-track-3", updateErr: errors.New("update failed")}
h := newTestHandlerWithStreaming(t, qa, &fakeReply{err: errors.New("markdown failed")}, card)
if err := h.Handle(context.Background(), directTextCallback()); err == nil {
t.Fatal("expected delivery error")
}
requireDelivery(t, qa.deliveries, core.DeliveryFailed, "", "", core.DeliveryFailureChannelDelivery)
})
}
func TestDeliveryAuditEventStreamingProductionPath(t *testing.T) {
qa := &fakeEventQA{
fakeStreamingQA: fakeStreamingQA{result: deliveryQAResult()},
events: []weknora.StreamEvent{{ResponseType: "answer", FullAnswer: "delivery answer", Done: true}},
}
card := &fakeStreamingCard{startID: "event-track-1"}
h := newTestHandlerWithStreaming(t, qa, &fakeReply{}, card)
h.cardUpdateInterval = time.Hour
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
requireDelivery(t, qa.deliveries, core.DeliverySent, core.DeliveryMethodCard, "event-track-1", "")
}
+140
View File
@@ -0,0 +1,140 @@
package core
import (
"context"
"encoding/json"
"strings"
"testing"
)
func TestDeliveryStatusConstants(t *testing.T) {
// Verify delivery status constants exist
if DeliveryGenerated != "generated" {
t.Errorf("DeliveryGenerated = %q, want 'generated'", DeliveryGenerated)
}
if DeliverySent != "sent" {
t.Errorf("DeliverySent = %q, want 'sent'", DeliverySent)
}
if DeliveryFailed != "failed" {
t.Errorf("DeliveryFailed = %q, want 'failed'", DeliveryFailed)
}
}
func TestDeliveryMethodConstants(t *testing.T) {
if DeliveryMethodCard != "card" {
t.Errorf("DeliveryMethodCard = %q, want 'card'", DeliveryMethodCard)
}
if DeliveryMethodMarkdown != "markdown" {
t.Errorf("DeliveryMethodMarkdown = %q, want 'markdown'", DeliveryMethodMarkdown)
}
}
func TestDeliveryFailureCodeConstants(t *testing.T) {
if DeliveryFailureChannelDelivery != "channel_delivery_failed" {
t.Errorf("DeliveryFailureChannelDelivery = %q, want 'channel_delivery_failed'",
DeliveryFailureChannelDelivery)
}
}
func TestDeliveryUpdateFields(t *testing.T) {
du := DeliveryUpdate{
TenantKey: "t1",
Channel: "dingtalk",
AnswerMessageID: "msg-1",
CorrelationID: "turn-1",
Status: DeliverySent,
DeliveryMethod: DeliveryMethodCard,
ProviderMessageID: "out-track-123",
FailureCode: "",
}
if du.TenantKey != "t1" {
t.Error("DeliveryUpdate must have TenantKey")
}
if du.Channel != "dingtalk" {
t.Error("DeliveryUpdate must have Channel")
}
if du.AnswerMessageID != "msg-1" {
t.Error("DeliveryUpdate must have AnswerMessageID")
}
if du.CorrelationID != "turn-1" {
t.Error("DeliveryUpdate must have CorrelationID")
}
if du.Status != DeliverySent {
t.Error("DeliveryUpdate must have Status")
}
if du.DeliveryMethod != DeliveryMethodCard {
t.Error("DeliveryUpdate must have DeliveryMethod")
}
if du.ProviderMessageID != "out-track-123" {
t.Error("DeliveryUpdate must have ProviderMessageID")
}
if du.FailureCode != "" {
t.Error("DeliveryUpdate FailureCode should be empty when not failed")
}
}
func TestDeliveryUpdateNoSecrets(t *testing.T) {
raw, err := json.Marshal(DeliveryUpdate{})
if err != nil {
t.Fatalf("marshal: %v", err)
}
s := string(raw)
for _, forbidden := range []string{"sessionWebhook", "token", "webhook", "raw_response", "raw_error", "outTrackID"} {
if containsCaseInsensitive(s, forbidden) {
t.Errorf("DeliveryUpdate JSON must not contain %q: %s", forbidden, s)
}
}
}
func TestQAResultHasAnswerMessageID(t *testing.T) {
qr := QAResult{
AnswerMessageID: "answer-msg-1",
DeliveryStatus: DeliveryGenerated,
}
if qr.AnswerMessageID != "answer-msg-1" {
t.Error("QAResult must have AnswerMessageID")
}
if qr.DeliveryStatus != DeliveryGenerated {
t.Error("QAResult must have DeliveryStatus")
}
}
func TestTurnHasDeliveryFields(t *testing.T) {
turn := Turn{
AnswerMessageID: "ans-1",
}
if turn.AnswerMessageID != "ans-1" {
t.Error("Turn must have AnswerMessageID")
}
// DeliveryStatus and DeliveryMethod fields on Turn for loadTurn recovery
// These are set by the store when loading
turn.AnswerDeliveryStatus = DeliverySent
turn.AnswerDeliveryMethod = DeliveryMethodCard
if turn.AnswerDeliveryStatus != DeliverySent {
t.Error("Turn must have AnswerDeliveryStatus")
}
if turn.AnswerDeliveryMethod != DeliveryMethodCard {
t.Error("Turn must have AnswerDeliveryMethod")
}
}
func TestRepositoryHasRecordDelivery(t *testing.T) {
var repository Repository
var _ interface {
RecordDelivery(context.Context, DeliveryUpdate) error
} = repository
}
func TestErrDeliveryUpdateFailedExists(t *testing.T) {
if ErrDeliveryUpdateFailed == nil {
t.Error("ErrDeliveryUpdateFailed must be defined")
}
if ErrDeliveryUpdateFailed.Error() == "" {
t.Error("ErrDeliveryUpdateFailed must have a message")
}
}
func containsCaseInsensitive(s, substr string) bool {
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
}
+57 -19
View File
@@ -60,6 +60,8 @@ type Reference struct {
type QAResult struct {
TurnID string `json:"turn_id"`
AnswerMessageID string `json:"answer_message_id,omitempty"`
DeliveryStatus string `json:"delivery_status,omitempty"`
WeKnoraSessionID string `json:"weknora_session_id"`
WeKnoraRequestID string `json:"weknora_request_id,omitempty"`
Answer string `json:"answer"`
@@ -78,33 +80,69 @@ type SessionMapping struct {
}
type Turn struct {
ID string
ID string
TenantKey string
Channel string
ExternalMessageID string
SessionKey string
WeKnoraSessionID string
WeKnoraRequestID string
AgentID string
ConversationType ConversationType
ExternalChatID string
ExternalUserID string
SenderName string
MessageType string
ActorID string
ConversationID string
RequestMessageID string
AnswerMessageID string
AnswerDeliveryStatus string
AnswerDeliveryMethod string
Question string
Answer string
References []Reference
Media []MediaAudit
Status string
ErrorMessage string
}
// Delivery status constants for outbound messages.
const (
DeliveryGenerated = "generated"
DeliverySent = "sent"
DeliveryFailed = "failed"
)
// Delivery method constants.
const (
DeliveryMethodCard = "card"
DeliveryMethodMarkdown = "markdown"
)
// DeliveryFailureCode constants.
const (
DeliveryFailureChannelDelivery = "channel_delivery_failed"
)
// DeliveryUpdate carries the minimum safe fields needed to record a delivery outcome.
// It must never contain sessionWebhook, tokens, raw API responses, or raw error text.
type DeliveryUpdate struct {
TenantKey string
Channel string
ExternalMessageID string
SessionKey string
WeKnoraSessionID string
WeKnoraRequestID string
AgentID string
ConversationType ConversationType
ExternalChatID string
ExternalUserID string
SenderName string
MessageType string
ActorID string
ConversationID string
RequestMessageID string
AnswerMessageID string
Question string
Answer string
References []Reference
Media []MediaAudit
CorrelationID string
Status string
ErrorMessage string
DeliveryMethod string
ProviderMessageID string
FailureCode string
}
var ErrDeliveryUpdateFailed = errors.New("delivery update failed: RowsAffected != 1")
type Repository interface {
BeginTurn(ctx context.Context, turn Turn) (Turn, bool, error)
ResolveSession(ctx context.Context, mapping SessionMapping, create func(context.Context) (string, error)) (string, error)
FinishTurn(ctx context.Context, turn Turn) error
RecordDelivery(ctx context.Context, du DeliveryUpdate) error
}
+189
View File
@@ -0,0 +1,189 @@
package httpapi
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
)
type fakeAuditService struct {
listRequest audit.ListTurnsRequest
listResult audit.ListTurnsResult
listErr error
getID string
getResult audit.TurnDetail
getErr error
}
func (f *fakeAuditService) ListTurns(_ context.Context, request audit.ListTurnsRequest) (audit.ListTurnsResult, error) {
f.listRequest = request
return f.listResult, f.listErr
}
func (f *fakeAuditService) GetTurn(_ context.Context, id string) (audit.TurnDetail, error) {
f.getID = id
return f.getResult, f.getErr
}
func TestAuditOptionsRequireCompleteDistinctConfiguration(t *testing.T) {
for _, tc := range []struct {
name string
opts Options
}{
{name: "service only", opts: Options{AuditService: &fakeAuditService{}}},
{name: "token only", opts: Options{AdminToken: "admin-secret"}},
{name: "same ingress and admin token", opts: Options{AuditService: &fakeAuditService{}, AdminToken: "ingress-secret"}},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := NewWithOptions(fakeQA{}, "ingress-secret", tc.opts); err == nil {
t.Fatal("expected configuration error")
}
})
}
}
func TestAuditRoutesAreAbsentWhenDisabled(t *testing.T) {
server, err := New(fakeQA{}, "ingress-secret")
if err != nil {
t.Fatal(err)
}
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/v1/admin/audit/turns", nil))
if result.Code != http.StatusNotFound {
t.Fatalf("status=%d body=%s", result.Code, result.Body.String())
}
}
func TestAuditListRequiresIndependentAdminBearer(t *testing.T) {
service := &fakeAuditService{listResult: audit.ListTurnsResult{Items: []audit.TurnSummary{}}}
server, err := NewWithOptions(fakeQA{}, "ingress-secret", Options{AuditService: service, AdminToken: "admin-secret"})
if err != nil {
t.Fatal(err)
}
for _, authorization := range []string{"", "Bearer wrong", "Bearer ingress-secret", "bearer admin-secret", "Bearer admin-secret"} {
request := httptest.NewRequest(http.MethodGet, "/v1/admin/audit/turns", nil)
request.Header.Set("Authorization", authorization)
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
if result.Code != http.StatusUnauthorized || !strings.Contains(result.Body.String(), "unauthorized") {
t.Fatalf("auth=%q status=%d body=%s", authorization, result.Code, result.Body.String())
}
}
request := httptest.NewRequest(http.MethodGet, "/v1/admin/audit/turns", nil)
request.Header.Set("Authorization", "Bearer admin-secret")
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
if result.Code != http.StatusOK || result.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("status=%d cache=%q body=%s", result.Code, result.Header().Get("Cache-Control"), result.Body.String())
}
}
func TestAuditListParsesAllowedQueryOnly(t *testing.T) {
service := &fakeAuditService{listResult: audit.ListTurnsResult{Items: []audit.TurnSummary{}}}
server, _ := NewWithOptions(fakeQA{}, "ingress", Options{AuditService: service, AdminToken: "admin"})
request := httptest.NewRequest(http.MethodGet, "/v1/admin/audit/turns?limit=100&cursor=opaque&channel=dingtalk&status=succeeded", nil)
request.Header.Set("Authorization", "Bearer admin")
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
if result.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", result.Code, result.Body.String())
}
if service.listRequest.Limit != 100 || service.listRequest.Cursor != "opaque" || service.listRequest.Channel != "dingtalk" || service.listRequest.Status != audit.StatusSucceeded {
t.Fatalf("request=%+v", service.listRequest)
}
for _, query := range []string{"tenant_key=other", "unknown=x", "limit=0", "limit=101", "limit=x", "limit=1&limit=2"} {
request := httptest.NewRequest(http.MethodGet, "/v1/admin/audit/turns?"+query, nil)
request.Header.Set("Authorization", "Bearer admin")
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
if result.Code != http.StatusBadRequest {
t.Fatalf("query=%q status=%d body=%s", query, result.Code, result.Body.String())
}
}
}
func TestAuditListMapsServiceErrorsWithoutLeaking(t *testing.T) {
service := &fakeAuditService{listErr: errors.New("postgres password=private unavailable")}
server, _ := NewWithOptions(fakeQA{}, "ingress", Options{AuditService: service, AdminToken: "admin"})
request := httptest.NewRequest(http.MethodGet, "/v1/admin/audit/turns", nil)
request.Header.Set("Authorization", "Bearer admin")
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
if result.Code != http.StatusInternalServerError || strings.Contains(result.Body.String(), "password") || strings.Contains(result.Body.String(), "private") {
t.Fatalf("status=%d body=%s", result.Code, result.Body.String())
}
service.listErr = audit.ErrInvalidArgument
result = httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
if result.Code != http.StatusBadRequest {
t.Fatalf("invalid query status=%d", result.Code)
}
}
func TestAuditDetailReturnsSafeFullQuestionAndAnswer(t *testing.T) {
now := time.Date(2026, 7, 21, 0, 0, 0, 0, time.UTC)
service := &fakeAuditService{getResult: audit.TurnDetail{
ID: "turn-1", CreatedAt: now, Channel: "dingtalk", Status: audit.StatusSucceeded,
Question: "full question", Answer: "full answer",
References: []audit.SafeReference{{ID: "ref-1", KnowledgeTitle: "Manual", Score: 0.8}},
AnswerMessage: &audit.MessageMeta{ID: "answer-1", Direction: "outbound", Role: "assistant", MessageType: "text", Status: "sent", CreatedAt: now, DeliveryMethod: "card"},
}}
server, _ := NewWithOptions(fakeQA{}, "ingress", Options{AuditService: service, AdminToken: "admin"})
request := httptest.NewRequest(http.MethodGet, "/v1/admin/audit/turns/turn-1", nil)
request.Header.Set("Authorization", "Bearer admin")
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
body := result.Body.String()
if result.Code != http.StatusOK || service.getID != "turn-1" || !strings.Contains(body, "full question") || !strings.Contains(body, "full answer") {
t.Fatalf("status=%d id=%q body=%s", result.Code, service.getID, body)
}
for _, forbidden := range []string{"weknora_session", "weknora_request", "provider_message", "sessionWebhook", "token", "raw_error", "error_message", "content"} {
if strings.Contains(strings.ToLower(body), strings.ToLower(forbidden)) {
t.Fatalf("detail leaked forbidden %q: %s", forbidden, body)
}
}
}
func TestAuditDetailMapsNotFoundInvalidAndInternal(t *testing.T) {
service := &fakeAuditService{getErr: audit.ErrNotFound}
server, _ := NewWithOptions(fakeQA{}, "ingress", Options{AuditService: service, AdminToken: "admin"})
call := func(path string) *httptest.ResponseRecorder {
request := httptest.NewRequest(http.MethodGet, path, nil)
request.Header.Set("Authorization", "Bearer admin")
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
return result
}
if got := call("/v1/admin/audit/turns/missing"); got.Code != http.StatusNotFound {
t.Fatalf("not found status=%d", got.Code)
}
service.getErr = audit.ErrInvalidArgument
if got := call("/v1/admin/audit/turns/bad"); got.Code != http.StatusBadRequest {
t.Fatalf("invalid status=%d", got.Code)
}
service.getErr = errors.New("raw database secret")
if got := call("/v1/admin/audit/turns/turn"); got.Code != http.StatusInternalServerError || strings.Contains(got.Body.String(), "secret") {
t.Fatalf("internal status=%d body=%s", got.Code, got.Body.String())
}
}
func TestValidateLoopbackListenAddress(t *testing.T) {
for _, address := range []string{"127.0.0.1:8090", "localhost:8090", "[::1]:8090"} {
if err := ValidateLoopbackListenAddress(address); err != nil {
t.Errorf("address=%q error=%v", address, err)
}
}
for _, address := range []string{"0.0.0.0:8090", "192.168.1.5:8090", ":8090", "127.0.0.1", "example.com:8090"} {
if err := ValidateLoopbackListenAddress(address); err == nil {
t.Errorf("address=%q should fail", address)
}
}
}
+155 -8
View File
@@ -5,10 +5,14 @@ import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
@@ -16,39 +20,88 @@ type QAService interface {
Ask(context.Context, core.QARequest) (core.QAResult, error)
}
type AuditService interface {
ListTurns(context.Context, audit.ListTurnsRequest) (audit.ListTurnsResult, error)
GetTurn(context.Context, string) (audit.TurnDetail, error)
}
type Options struct {
AuditService AuditService
AdminToken string
}
type Server struct {
service QAService
token string
mux *http.ServeMux
service QAService
ingressToken string
auditService AuditService
adminToken string
mux *http.ServeMux
}
func New(service QAService, ingressToken string) (*Server, error) {
return NewWithOptions(service, ingressToken, Options{})
}
func NewWithOptions(service QAService, ingressToken string, opts Options) (*Server, error) {
if service == nil {
return nil, errors.New("QA service is required")
}
if strings.TrimSpace(ingressToken) == "" {
return nil, errors.New("DOBOTAI_INGRESS_TOKEN is required")
}
s := &Server{service: service, token: ingressToken, mux: http.NewServeMux()}
adminToken := strings.TrimSpace(opts.AdminToken)
hasAuditService := opts.AuditService != nil
hasAdminToken := adminToken != ""
if hasAuditService != hasAdminToken {
return nil, errors.New("audit service and DOBOTAI_ADMIN_TOKEN must be configured together")
}
if hasAdminToken && constantTimeTokenEqual(adminToken, ingressToken) {
return nil, errors.New("DOBOTAI_ADMIN_TOKEN must differ from DOBOTAI_INGRESS_TOKEN")
}
s := &Server{
service: service, ingressToken: ingressToken,
auditService: opts.AuditService, adminToken: adminToken,
mux: http.NewServeMux(),
}
s.mux.HandleFunc("GET /healthz", s.health)
s.mux.HandleFunc("POST /v1/qa", s.authorize(s.ask))
s.mux.HandleFunc("POST /v1/qa", s.authorizeToken(s.ingressToken, s.ask))
if hasAuditService {
s.mux.HandleFunc("GET /v1/admin/audit/turns", s.authorizeToken(s.adminToken, s.listAuditTurns))
s.mux.HandleFunc("GET /v1/admin/audit/turns/{id}", s.authorizeToken(s.adminToken, s.getAuditTurn))
}
return s, nil
}
func (s *Server) Handler() http.Handler { return s.mux }
func (s *Server) authorize(next http.HandlerFunc) http.HandlerFunc {
func (s *Server) authorizeToken(expected string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
provided := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if len(provided) != len(s.token) || subtle.ConstantTimeCompare([]byte(provided), []byte(s.token)) != 1 {
if strings.HasPrefix(r.URL.Path, "/v1/admin/audit/") {
w.Header().Set("Cache-Control", "no-store")
}
header := r.Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
provided := strings.TrimPrefix(header, "Bearer ")
if provided == "" || strings.ContainsAny(provided, " \t\r\n") || !constantTimeTokenEqual(provided, expected) {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
next(w, r)
}
}
func constantTimeTokenEqual(provided, expected string) bool {
return len(provided) == len(expected) && subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
}
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) ask(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
defer r.Body.Close()
@@ -82,9 +135,103 @@ func (s *Server) ask(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, result)
}
func (s *Server) listAuditTurns(w http.ResponseWriter, r *http.Request) {
allowed := map[string]bool{"limit": true, "cursor": true, "channel": true, "status": true}
for key, values := range r.URL.Query() {
if !allowed[key] || len(values) != 1 {
writeError(w, http.StatusBadRequest, "invalid audit query")
return
}
}
var request audit.ListTurnsRequest
if raw, ok := singleQueryValue(r, "limit"); ok {
limit, err := strconv.Atoi(raw)
if err != nil || limit < 1 || limit > 100 {
writeError(w, http.StatusBadRequest, "invalid audit query")
return
}
request.Limit = limit
}
request.Cursor = r.URL.Query().Get("cursor")
request.Channel = r.URL.Query().Get("channel")
request.Status = audit.TurnStatus(r.URL.Query().Get("status"))
if len(request.Cursor) > 512 || len(request.Channel) > 128 || !validAuditStatus(request.Status) {
writeError(w, http.StatusBadRequest, "invalid audit query")
return
}
result, err := s.auditService.ListTurns(r.Context(), request)
if err != nil {
writeAuditError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
func (s *Server) getAuditTurn(w http.ResponseWriter, r *http.Request) {
if len(r.URL.Query()) != 0 {
writeError(w, http.StatusBadRequest, "invalid audit query")
return
}
result, err := s.auditService.GetTurn(r.Context(), r.PathValue("id"))
if err != nil {
writeAuditError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
func singleQueryValue(r *http.Request, key string) (string, bool) {
values, ok := r.URL.Query()[key]
if !ok || len(values) != 1 {
return "", false
}
return values[0], true
}
func validAuditStatus(status audit.TurnStatus) bool {
switch status {
case "", audit.StatusProcessing, audit.StatusSucceeded, audit.StatusFailed, audit.StatusCancelled:
return true
default:
return false
}
}
func writeAuditError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, audit.ErrInvalidArgument):
writeError(w, http.StatusBadRequest, "invalid audit query")
case errors.Is(err, audit.ErrNotFound):
writeError(w, http.StatusNotFound, "audit turn not found")
default:
writeError(w, http.StatusInternalServerError, "audit query failed")
}
}
func ValidateLoopbackListenAddress(address string) error {
host, port, err := net.SplitHostPort(strings.TrimSpace(address))
if err != nil || host == "" || port == "" {
return errors.New("audit API requires a loopback host and explicit port")
}
portNumber, err := strconv.Atoi(port)
if err != nil || portNumber < 1 || portNumber > 65535 {
return errors.New("audit API requires a valid port")
}
if strings.EqualFold(host, "localhost") {
return nil
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return fmt.Errorf("audit API listen host %q is not loopback", host)
}
return nil
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
+7 -1
View File
@@ -59,6 +59,10 @@ func (s *Service) AskEvents(ctx context.Context, request core.QARequest, observe
return s.ask(ctx, request, nil, observer)
}
func (s *Service) RecordDelivery(ctx context.Context, update core.DeliveryUpdate) error {
return s.repository.RecordDelivery(ctx, update)
}
func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserver weknora.AnswerObserver, eventObserver weknora.StreamObserver) (core.QAResult, error) {
request = normalizeRequestIdentity(request)
if err := validateRequest(request); err != nil {
@@ -115,6 +119,7 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
if stored.Status == "succeeded" {
return core.QAResult{TurnID: stored.ID, WeKnoraSessionID: stored.WeKnoraSessionID,
WeKnoraRequestID: stored.WeKnoraRequestID, Answer: stored.Answer,
AnswerMessageID: stored.AnswerMessageID, DeliveryStatus: stored.AnswerDeliveryStatus,
References: stored.References, Replayed: true}, nil
}
return core.QAResult{}, core.ErrTurnInProgress
@@ -202,7 +207,8 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
if err := s.repository.FinishTurn(ctx, turn); err != nil {
return core.QAResult{}, fmt.Errorf("finish turn: %w", err)
}
return core.QAResult{TurnID: turn.ID, WeKnoraSessionID: sessionID,
return core.QAResult{TurnID: turn.ID, AnswerMessageID: turn.AnswerMessageID,
DeliveryStatus: core.DeliveryGenerated, WeKnoraSessionID: sessionID,
WeKnoraRequestID: chat.RequestID, Answer: chat.Answer, References: chat.References}, nil
}
+60
View File
@@ -18,6 +18,7 @@ type memoryRepository struct {
sessions map[string]string
beginTurns []core.Turn
finishTurns []core.Turn
deliveries []core.DeliveryUpdate
}
func newMemoryRepository() *memoryRepository {
@@ -60,6 +61,12 @@ func (r *memoryRepository) FinishTurn(_ context.Context, turn core.Turn) error {
r.turns[turn.TenantKey+":"+turn.Channel+":"+turn.ExternalMessageID] = turn
return nil
}
func (r *memoryRepository) RecordDelivery(_ context.Context, update core.DeliveryUpdate) error {
r.mu.Lock()
defer r.mu.Unlock()
r.deliveries = append(r.deliveries, update)
return nil
}
type fixedResolver struct{}
@@ -583,3 +590,56 @@ func TestAskRejectsNonQAMessageTypes(t *testing.T) {
})
}
}
func TestAskReturnsGeneratedDeliveryIdentityAndReplay(t *testing.T) {
repo := newMemoryRepository()
service, err := New(repo, fixedResolver{}, &fakeWeKnora{}, session.GroupShared)
if err != nil {
t.Fatal(err)
}
req := core.QARequest{
TenantKey: "tenant", Channel: "dingtalk", ConversationType: core.ConversationDirect,
ExternalUserID: "user-1", ExternalMessageID: "message-delivery", Query: "question",
}
first, err := service.Ask(context.Background(), req)
if err != nil {
t.Fatal(err)
}
if first.AnswerMessageID == "" || first.DeliveryStatus != core.DeliveryGenerated {
t.Fatalf("new result delivery identity incomplete: %+v", first)
}
repo.mu.Lock()
key := req.TenantKey + ":" + req.Channel + ":" + req.ExternalMessageID
stored := repo.turns[key]
stored.AnswerDeliveryStatus = core.DeliverySent
repo.turns[key] = stored
repo.mu.Unlock()
replay, err := service.Ask(context.Background(), req)
if err != nil {
t.Fatal(err)
}
if !replay.Replayed || replay.AnswerMessageID != first.AnswerMessageID || replay.DeliveryStatus != core.DeliverySent {
t.Fatalf("replay delivery identity incomplete: first=%+v replay=%+v", first, replay)
}
}
func TestRecordDeliveryDelegatesToRepository(t *testing.T) {
repo := newMemoryRepository()
service, err := New(repo, fixedResolver{}, &fakeWeKnora{}, session.GroupShared)
if err != nil {
t.Fatal(err)
}
update := core.DeliveryUpdate{
TenantKey: "tenant", Channel: "dingtalk", AnswerMessageID: "answer-1", CorrelationID: "turn-1",
Status: core.DeliverySent, DeliveryMethod: core.DeliveryMethodCard, ProviderMessageID: "track-1",
}
if err := service.RecordDelivery(context.Background(), update); err != nil {
t.Fatal(err)
}
repo.mu.Lock()
defer repo.mu.Unlock()
if len(repo.deliveries) != 1 || repo.deliveries[0] != update {
t.Fatalf("delivery update not delegated: %+v", repo.deliveries)
}
}
+167
View File
@@ -0,0 +1,167 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
const listAuditTurnsSQL = `SELECT
t.id,t.created_at,t.channel,t.status,
COALESCE(c.conversation_type,''),COALESCE(c.title,''),
COALESCE(a.external_actor_id,''),COALESCE(rm.actor_name_snapshot,''),
LEFT(t.question,200),LEFT(t.answer,200),
CASE WHEN jsonb_typeof(t.references_json)='array' THEN jsonb_array_length(t.references_json) ELSE 0 END,
COALESCE(am.status,''),COALESCE(am.delivery_method,'')
FROM turns t
LEFT JOIN messages rm ON rm.id=t.request_message_id AND rm.tenant_key=t.tenant_key AND rm.channel=t.channel
LEFT JOIN messages am ON am.id=t.answer_message_id AND am.tenant_key=t.tenant_key AND am.channel=t.channel
LEFT JOIN channel_conversations c ON c.id=rm.conversation_id AND c.tenant_key=t.tenant_key AND c.channel=t.channel
LEFT JOIN channel_actors a ON a.id=rm.actor_id AND a.tenant_key=t.tenant_key AND a.channel=t.channel
WHERE t.tenant_key=$1
AND ($2='' OR t.channel=$2)
AND ($3='' OR t.status=$3)
AND ($4::timestamptz IS NULL OR (t.created_at,t.id)<($4,$5))
ORDER BY t.created_at DESC,t.id DESC
LIMIT $6`
const getAuditTurnSQL = `SELECT
t.id,t.created_at,t.channel,t.status,
COALESCE(c.conversation_type,''),COALESCE(c.title,''),
COALESCE(a.external_actor_id,''),COALESCE(rm.actor_name_snapshot,''),
t.question,t.answer,t.references_json,t.media_json,
COALESCE(rm.id,''),COALESCE(rm.direction,''),COALESCE(rm.role,''),COALESCE(rm.message_type,''),COALESCE(rm.status,''),COALESCE(rm.actor_name_snapshot,''),rm.created_at,
COALESCE(am.id,''),COALESCE(am.direction,''),COALESCE(am.role,''),COALESCE(am.message_type,''),COALESCE(am.status,''),am.created_at,am.delivered_at,COALESCE(am.delivery_method,''),COALESCE(am.failure_code,'')
FROM turns t
LEFT JOIN messages rm ON rm.id=t.request_message_id AND rm.tenant_key=t.tenant_key AND rm.channel=t.channel
LEFT JOIN messages am ON am.id=t.answer_message_id AND am.tenant_key=t.tenant_key AND am.channel=t.channel
LEFT JOIN channel_conversations c ON c.id=rm.conversation_id AND c.tenant_key=t.tenant_key AND c.channel=t.channel
LEFT JOIN channel_actors a ON a.id=rm.actor_id AND a.tenant_key=t.tenant_key AND a.channel=t.channel
WHERE t.tenant_key=$1 AND t.id=$2`
func (s *Store) ListTurns(ctx context.Context, tenantKey string, query audit.ListQuery) ([]audit.TurnSummary, error) {
var before any
if query.BeforeCreatedAt != nil {
before = *query.BeforeCreatedAt
}
rows, err := s.pool.Query(ctx, listAuditTurnsSQL, tenantKey, query.Channel, string(query.Status), before, query.BeforeID, query.Limit)
if err != nil {
return nil, fmt.Errorf("list audit turns: %w", err)
}
defer rows.Close()
items := make([]audit.TurnSummary, 0)
for rows.Next() {
var item audit.TurnSummary
var status string
if err := rows.Scan(
&item.ID, &item.CreatedAt, &item.Channel, &status,
&item.ConversationType, &item.ConversationTitle,
&item.ActorExternalID, &item.ActorDisplayName,
&item.QuestionSummary, &item.AnswerSummary, &item.ReferenceCount,
&item.AnswerDeliveryStatus, &item.AnswerDeliveryMethod,
); err != nil {
return nil, fmt.Errorf("scan audit turn summary: %w", err)
}
item.Status = audit.TurnStatus(status)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate audit turns: %w", err)
}
return items, nil
}
func (s *Store) GetTurn(ctx context.Context, tenantKey, turnID string) (audit.TurnDetail, error) {
var detail audit.TurnDetail
var status string
var referencesJSON, mediaJSON []byte
var request audit.MessageMeta
var answer audit.MessageMeta
var requestCreated, answerCreated, deliveredAt *time.Time
err := s.pool.QueryRow(ctx, getAuditTurnSQL, tenantKey, turnID).Scan(
&detail.ID, &detail.CreatedAt, &detail.Channel, &status,
&detail.ConversationType, &detail.ConversationTitle,
&detail.ActorExternalID, &detail.ActorDisplayName,
&detail.Question, &detail.Answer, &referencesJSON, &mediaJSON,
&request.ID, &request.Direction, &request.Role, &request.MessageType, &request.Status, &request.ActorName, &requestCreated,
&answer.ID, &answer.Direction, &answer.Role, &answer.MessageType, &answer.Status, &answerCreated, &deliveredAt, &answer.DeliveryMethod, &answer.FailureCode,
)
if errors.Is(err, pgx.ErrNoRows) {
return audit.TurnDetail{}, audit.ErrNotFound
}
if err != nil {
return audit.TurnDetail{}, fmt.Errorf("get audit turn: %w", err)
}
detail.Status = audit.TurnStatus(status)
var references []core.Reference
if len(referencesJSON) > 0 {
if err := json.Unmarshal(referencesJSON, &references); err != nil {
return audit.TurnDetail{}, fmt.Errorf("decode audit references: %w", err)
}
}
detail.References = safeAuditReferences(references)
detail.ReferenceCount = len(detail.References)
var media []core.MediaAudit
if len(mediaJSON) > 0 {
if err := json.Unmarshal(mediaJSON, &media); err != nil {
return audit.TurnDetail{}, fmt.Errorf("decode audit media: %w", err)
}
}
detail.Media = safeAuditMedia(media)
if request.ID != "" {
if requestCreated != nil {
request.CreatedAt = *requestCreated
}
detail.RequestMessage = &request
}
if answer.ID != "" {
if answerCreated != nil {
answer.CreatedAt = *answerCreated
}
answer.DeliveredAt = deliveredAt
detail.AnswerMessage = &answer
detail.AnswerDeliveryStatus = answer.Status
detail.AnswerDeliveryMethod = answer.DeliveryMethod
}
return detail, nil
}
func safeAuditReferences(references []core.Reference) []audit.SafeReference {
if len(references) == 0 {
return nil
}
safe := make([]audit.SafeReference, 0, len(references))
for _, reference := range references {
safe = append(safe, audit.SafeReference{
ID: reference.ID, KnowledgeID: reference.KnowledgeID,
KnowledgeTitle: reference.KnowledgeTitle, Score: reference.Score,
})
}
return safe
}
func safeAuditMedia(media []core.MediaAudit) []audit.SafeMedia {
if len(media) == 0 {
return nil
}
safe := make([]audit.SafeMedia, 0, len(media))
for _, item := range media {
safe = append(safe, audit.SafeMedia{
Type: item.Type, MIMEType: item.MIMEType, SizeBytes: item.SizeBytes,
Width: item.Width, Height: item.Height,
})
}
return safe
}
var _ audit.TurnRepository = (*Store)(nil)
@@ -0,0 +1,75 @@
package postgres
import (
"encoding/json"
"strings"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
func TestAuditListSQLTenantSafeAndKeyset(t *testing.T) {
sql := strings.ToUpper(listAuditTurnsSQL)
for _, want := range []string{
"FROM TURNS T", "T.TENANT_KEY=$1", "RM.TENANT_KEY=T.TENANT_KEY", "RM.CHANNEL=T.CHANNEL",
"AM.TENANT_KEY=T.TENANT_KEY", "AM.CHANNEL=T.CHANNEL",
"C.TENANT_KEY=T.TENANT_KEY", "C.CHANNEL=T.CHANNEL",
"A.TENANT_KEY=T.TENANT_KEY", "A.CHANNEL=T.CHANNEL",
"(T.CREATED_AT,T.ID)<($4,$5)", "ORDER BY T.CREATED_AT DESC,T.ID DESC", "LIMIT $6",
} {
if !strings.Contains(sql, want) {
t.Errorf("list audit SQL missing %q", want)
}
}
}
func TestAuditListSQLHandlesHistoricalJSONNullReferences(t *testing.T) {
sql := strings.ToUpper(listAuditTurnsSQL)
if !strings.Contains(sql, "CASE WHEN JSONB_TYPEOF(T.REFERENCES_JSON)='ARRAY'") ||
!strings.Contains(sql, "THEN JSONB_ARRAY_LENGTH(T.REFERENCES_JSON) ELSE 0 END") {
t.Fatal("list audit SQL must not call jsonb_array_length on historical JSON null/scalars")
}
}
func TestAuditDetailSQLTenantSafeAndExcludesForbiddenData(t *testing.T) {
sql := strings.ToUpper(getAuditTurnSQL)
for _, want := range []string{
"T.TENANT_KEY=$1", "T.ID=$2", "RM.TENANT_KEY=T.TENANT_KEY", "AM.TENANT_KEY=T.TENANT_KEY",
"C.TENANT_KEY=T.TENANT_KEY", "A.TENANT_KEY=T.TENANT_KEY",
} {
if !strings.Contains(sql, want) {
t.Errorf("detail audit SQL missing %q", want)
}
}
for _, forbidden := range []string{"ERROR_MESSAGE", "WEKNORA_SESSION_ID", "WEKNORA_REQUEST_ID", "PROVIDER_MESSAGE_ID", "SAFE_METADATA_JSON"} {
if strings.Contains(sql, forbidden) {
t.Errorf("detail audit SQL must not select forbidden %q", forbidden)
}
}
}
func TestSafeAuditReferencesDropContentAndRaw(t *testing.T) {
references := []core.Reference{{
ID: "ref-1", KnowledgeID: "kb-1", KnowledgeTitle: "Manual", Score: 0.9,
Content: "private chunk body", Raw: json.RawMessage(`{"private":"payload"}`),
}}
safe := safeAuditReferences(references)
if len(safe) != 1 || safe[0].ID != "ref-1" || safe[0].KnowledgeID != "kb-1" || safe[0].KnowledgeTitle != "Manual" || safe[0].Score != 0.9 {
t.Fatalf("safe references=%+v", safe)
}
raw, err := json.Marshal(safe)
if err != nil {
t.Fatal(err)
}
body := string(raw)
if strings.Contains(body, "private chunk") || strings.Contains(body, "payload") || strings.Contains(strings.ToLower(body), "content") || strings.Contains(strings.ToLower(body), "raw") {
t.Fatalf("safe references leaked forbidden data: %s", body)
}
}
func TestSafeAuditMediaCopiesMetadataOnly(t *testing.T) {
media := safeAuditMedia([]core.MediaAudit{{Type: "image", MIMEType: "image/png", SizeBytes: 42, Width: 10, Height: 20}})
if len(media) != 1 || media[0].Type != "image" || media[0].MIMEType != "image/png" || media[0].SizeBytes != 42 || media[0].Width != 10 || media[0].Height != 20 {
t.Fatalf("safe media=%+v", media)
}
}
@@ -0,0 +1,91 @@
package postgres
import (
"os"
"strings"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
func TestValidateDeliveryUpdate(t *testing.T) {
validSent := core.DeliveryUpdate{
TenantKey: "tenant", Channel: "dingtalk", AnswerMessageID: "answer-1", CorrelationID: "turn-1",
Status: core.DeliverySent, DeliveryMethod: core.DeliveryMethodCard, ProviderMessageID: "track-1",
}
validFailed := core.DeliveryUpdate{
TenantKey: "tenant", Channel: "dingtalk", AnswerMessageID: "answer-1", CorrelationID: "turn-1",
Status: core.DeliveryFailed, FailureCode: core.DeliveryFailureChannelDelivery,
}
tests := []struct {
name string
update core.DeliveryUpdate
wantErr bool
}{
{name: "sent-card", update: validSent},
{name: "sent-markdown", update: func() core.DeliveryUpdate {
u := validSent
u.DeliveryMethod = core.DeliveryMethodMarkdown
u.ProviderMessageID = ""
return u
}()},
{name: "failed", update: validFailed},
{name: "missing-tenant", update: func() core.DeliveryUpdate { u := validSent; u.TenantKey = ""; return u }(), wantErr: true},
{name: "missing-correlation", update: func() core.DeliveryUpdate { u := validSent; u.CorrelationID = ""; return u }(), wantErr: true},
{name: "generated-not-terminal", update: func() core.DeliveryUpdate { u := validSent; u.Status = core.DeliveryGenerated; return u }(), wantErr: true},
{name: "sent-needs-method", update: func() core.DeliveryUpdate { u := validSent; u.DeliveryMethod = ""; return u }(), wantErr: true},
{name: "sent-no-failure", update: func() core.DeliveryUpdate {
u := validSent
u.FailureCode = core.DeliveryFailureChannelDelivery
return u
}(), wantErr: true},
{name: "failed-needs-safe-code", update: func() core.DeliveryUpdate { u := validFailed; u.FailureCode = "raw timeout from webhook"; return u }(), wantErr: true},
{name: "failed-no-provider-id", update: func() core.DeliveryUpdate { u := validFailed; u.ProviderMessageID = "track"; return u }(), wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validateDeliveryUpdate(tc.update)
if (err != nil) != tc.wantErr {
t.Fatalf("validateDeliveryUpdate error=%v wantErr=%v", err, tc.wantErr)
}
})
}
}
func TestRecordDeliverySQLIsTenantSafeAndMonotonic(t *testing.T) {
data, err := os.ReadFile("store.go")
if err != nil {
t.Fatal(err)
}
src := strings.ToUpper(string(data))
for _, fragment := range []string{
"UPDATE MESSAGES AS M", "FROM TURNS T", "M.TENANT_KEY=$1", "M.CHANNEL=$2",
"M.ID=$3", "M.CORRELATION_ID=$4", "T.ANSWER_MESSAGE_ID=M.ID",
"M.DIRECTION='OUTBOUND'", "M.ROLE='ASSISTANT'", "DELIVERED_AT=CASE",
"COALESCE(M.DELIVERED_AT,NOW())", "M.STATUS IN ('GENERATED','FAILED','SENT')",
"$5='FAILED' AND (M.STATUS='GENERATED' OR M.STATUS='FAILED')", "ROWSAFFECTED",
} {
if !strings.Contains(src, fragment) {
t.Errorf("RecordDelivery SQL missing %q", fragment)
}
}
if strings.Contains(src, "DELIVEREDAT") || strings.Contains(src, "DELIVERED_AT=$") {
t.Error("delivery timestamp must be generated by PostgreSQL NOW(), not supplied by caller")
}
}
func TestLoadTurnRestoresAnswerDeliveryState(t *testing.T) {
data, err := os.ReadFile("store.go")
if err != nil {
t.Fatal(err)
}
src := strings.ToUpper(string(data))
for _, fragment := range []string{
"LEFT JOIN MESSAGES AM ON AM.ID=T.ANSWER_MESSAGE_ID",
"AM.STATUS", "AM.DELIVERY_METHOD", "ANSWERDELIVERYSTATUS", "ANSWERDELIVERYMETHOD",
} {
if !strings.Contains(src, fragment) {
t.Errorf("loadTurn delivery restore missing %q", fragment)
}
}
}
+72 -2
View File
@@ -186,6 +186,40 @@ func (s *Store) FinishTurn(ctx context.Context, turn core.Turn) error {
return tx.Commit(ctx)
}
func (s *Store) RecordDelivery(ctx context.Context, update core.DeliveryUpdate) error {
if err := validateDeliveryUpdate(update); err != nil {
return err
}
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
command, err := tx.Exec(ctx, `UPDATE messages AS m SET
status=$5,
delivery_method=CASE WHEN $5='sent' THEN $6 ELSE '' END,
provider_message_id=CASE WHEN $5='sent' THEN $7 ELSE '' END,
failure_code=CASE WHEN $5='failed' THEN $8 ELSE '' END,
delivered_at=CASE WHEN $5='sent' THEN COALESCE(m.delivered_at,NOW()) ELSE NULL END,
updated_at=NOW()
FROM turns t
WHERE m.tenant_key=$1 AND m.channel=$2 AND m.id=$3 AND m.correlation_id=$4
AND m.direction='outbound' AND m.role='assistant'
AND t.id=$4 AND t.tenant_key=$1 AND t.channel=$2 AND t.answer_message_id=m.id
AND (($5='sent' AND m.status IN ('generated','failed','sent'))
OR ($5='failed' AND (m.status='generated' OR m.status='failed')))`,
update.TenantKey, update.Channel, update.AnswerMessageID, update.CorrelationID,
update.Status, update.DeliveryMethod, update.ProviderMessageID, update.FailureCode)
if err != nil {
return err
}
if command.RowsAffected() != 1 {
return core.ErrDeliveryUpdateFailed
}
return tx.Commit(ctx)
}
type rowQuerier interface {
QueryRow(context.Context, string, ...any) pgx.Row
}
@@ -201,14 +235,17 @@ func loadTurnWith(ctx context.Context, query rowQuerier, tenantKey, channel, mes
var agentID, requestMessageID, answerMessageID *string
var actorID, conversationID, messageType, actorName *string
var conversationType, externalConversationID, externalActorID *string
var answerDeliveryStatus, answerDeliveryMethod *string
err := query.QueryRow(ctx, `SELECT
t.id,t.tenant_key,t.channel,t.external_message_id,t.session_key,t.weknora_session_id,
t.weknora_request_id,t.question,t.answer,t.references_json,t.media_json,t.status,t.error_message,
t.agent_id,t.request_message_id,t.answer_message_id,
m.actor_id,m.conversation_id,m.message_type,m.actor_name_snapshot,
c.conversation_type,c.external_conversation_id,a.external_actor_id
c.conversation_type,c.external_conversation_id,a.external_actor_id,
am.status,am.delivery_method
FROM turns t
LEFT JOIN messages m ON m.id=t.request_message_id
LEFT JOIN messages am ON am.id=t.answer_message_id
LEFT JOIN channel_conversations c ON c.id=m.conversation_id
LEFT JOIN channel_actors a ON a.id=m.actor_id
WHERE t.tenant_key=$1 AND t.channel=$2 AND t.external_message_id=$3`, tenantKey, channel, messageID).Scan(
@@ -216,7 +253,7 @@ func loadTurnWith(ctx context.Context, query rowQuerier, tenantKey, channel, mes
&turn.WeKnoraSessionID, &turn.WeKnoraRequestID, &turn.Question, &turn.Answer, &references,
&mediaJSON, &turn.Status, &turn.ErrorMessage, &agentID, &requestMessageID, &answerMessageID,
&actorID, &conversationID, &messageType, &actorName, &conversationType,
&externalConversationID, &externalActorID)
&externalConversationID, &externalActorID, &answerDeliveryStatus, &answerDeliveryMethod)
if err != nil {
return core.Turn{}, err
}
@@ -229,6 +266,12 @@ func loadTurnWith(ctx context.Context, query rowQuerier, tenantKey, channel, mes
if answerMessageID != nil {
turn.AnswerMessageID = *answerMessageID
}
if answerDeliveryStatus != nil {
turn.AnswerDeliveryStatus = *answerDeliveryStatus
}
if answerDeliveryMethod != nil {
turn.AnswerDeliveryMethod = *answerDeliveryMethod
}
if actorID != nil {
turn.ActorID = *actorID
}
@@ -269,6 +312,33 @@ func shouldCreateAnswerMessage(turn core.Turn) bool {
strings.TrimSpace(turn.AnswerMessageID) != ""
}
func validateDeliveryUpdate(update core.DeliveryUpdate) error {
if strings.TrimSpace(update.TenantKey) == "" || strings.TrimSpace(update.Channel) == "" ||
strings.TrimSpace(update.AnswerMessageID) == "" || strings.TrimSpace(update.CorrelationID) == "" {
return errors.New("delivery update identity is required")
}
switch update.Status {
case core.DeliverySent:
if update.DeliveryMethod != core.DeliveryMethodCard && update.DeliveryMethod != core.DeliveryMethodMarkdown {
return errors.New("sent delivery requires a supported method")
}
if update.FailureCode != "" {
return errors.New("sent delivery cannot have a failure code")
}
if update.DeliveryMethod == core.DeliveryMethodMarkdown && update.ProviderMessageID != "" {
return errors.New("markdown delivery cannot have a provider message id")
}
case core.DeliveryFailed:
if update.DeliveryMethod != "" || update.ProviderMessageID != "" ||
update.FailureCode != core.DeliveryFailureChannelDelivery {
return errors.New("failed delivery requires a safe failure code only")
}
default:
return errors.New("delivery status must be sent or failed")
}
return nil
}
func externalConversationID(turn core.Turn) string {
if externalChatID := strings.TrimSpace(turn.ExternalChatID); externalChatID != "" {
return externalChatID