Files
Assistant/cmd/core-runtime/capability_chat.go
m 924d8c3767
CI / test (push) Successful in 18s
CI / test (pull_request) Successful in 16s
DEV-000068 activate runtime experience loop
2026-08-17 18:35:46 +03:00

262 lines
9.8 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"git.cin.su/m/Assistant/adapters/postgres"
"git.cin.su/m/Assistant/contracts/authority"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/continuity"
"git.cin.su/m/Assistant/contracts/identity"
"git.cin.su/m/Assistant/contracts/interaction"
capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain"
"git.cin.su/m/Assistant/platform/ids"
)
type continuityReminderClient struct {
baseURL *url.URL
client *http.Client
}
type continuityResponseClient struct {
baseURL *url.URL
client *http.Client
}
func newContinuityReminderClient(rawURL string, client *http.Client) (continuityReminderClient, error) {
base, err := url.Parse(rawURL)
if err != nil || base.Scheme == "" || base.Host == "" {
return continuityReminderClient{}, fmt.Errorf("invalid Continuity Runtime URL")
}
if client == nil {
client = http.DefaultClient
}
return continuityReminderClient{baseURL: base, client: client}, nil
}
func newContinuityResponseClient(rawURL string, client *http.Client) (continuityResponseClient, error) {
base, err := url.Parse(rawURL)
if err != nil || base.Scheme == "" || base.Host == "" {
return continuityResponseClient{}, fmt.Errorf("invalid Continuity Runtime URL")
}
if client == nil {
client = http.DefaultClient
}
return continuityResponseClient{baseURL: base, client: client}, nil
}
func (c continuityReminderClient) Schedule(ctx context.Context, authorization string, request continuity.ScheduleReminderRequest) (continuity.ScheduleReminderResult, error) {
target := *c.baseURL
target.Path = "/v1/creator/reminders"
payload, err := json.Marshal(request)
if err != nil {
return continuity.ScheduleReminderResult{}, err
}
upstream, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
if err != nil {
return continuity.ScheduleReminderResult{}, err
}
upstream.Header.Set("Content-Type", "application/json")
upstream.Header.Set("Authorization", authorization)
response, err := c.client.Do(upstream)
if err != nil {
return continuity.ScheduleReminderResult{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusCreated {
return continuity.ScheduleReminderResult{}, fmt.Errorf("continuity reminder status %d", response.StatusCode)
}
var result continuity.ScheduleReminderResult
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
return continuity.ScheduleReminderResult{}, err
}
if result.Item.ID == "" || result.Item.State != continuity.ProspectiveArmed {
return continuity.ScheduleReminderResult{}, fmt.Errorf("invalid reminder result")
}
return result, nil
}
func (c continuityResponseClient) Respond(ctx context.Context, authorization string, request continuity.HumanResponseRequest) (continuity.HumanResponseResult, error) {
target := *c.baseURL
target.Path = "/v1/creator/responses"
payload, err := json.Marshal(request)
if err != nil {
return continuity.HumanResponseResult{}, err
}
upstream, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
if err != nil {
return continuity.HumanResponseResult{}, err
}
upstream.Header.Set("Content-Type", "application/json")
upstream.Header.Set("Authorization", authorization)
response, err := c.client.Do(upstream)
if err != nil {
return continuity.HumanResponseResult{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusCreated {
return continuity.HumanResponseResult{}, fmt.Errorf("continuity response status %d", response.StatusCode)
}
var result continuity.HumanResponseResult
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
return continuity.HumanResponseResult{}, err
}
return result, nil
}
func resolveChatCapability(ctx context.Context, repository *postgres.CapabilityRepository, kind capability.CapabilityKind, session identity.CreatorSession, intent interaction.OriginalHumanIntent, utterance interaction.HumanUtterance, now time.Time) (capability.CapabilityResolution, error) {
requirementID, err := ids.New()
if err != nil {
return capability.CapabilityResolution{}, err
}
requirement := capability.CapabilityResolutionRequirement{
RequirementID: capability.RequirementRef(requirementID),
CapabilityKind: kind,
OwnerPrincipalID: session.PrincipalID,
ActorID: session.ActorID,
OriginalIntentID: capability.OriginalIntentRef(intent.ID),
SourceUtteranceID: capability.SourceUtteranceRef(utterance.ID),
RequiredConstraints: []capability.DataConstraint{capability.DataConstraintLocalOnly},
AuthorityContextRef: capability.AuthorityPolicyCreatorDirectiveV1,
ObservedAtUnixMillis: now.UnixMilli(),
}
candidates, err := repository.LoadCapabilityImplementations(ctx, kind, session.PrincipalID)
if err != nil {
return capability.CapabilityResolution{}, err
}
resolution, err := capabilitydomain.ResolveCapability(requirement, candidates)
if errors.Is(err, capabilitydomain.ErrNoCapabilityImplementation) && resolution.Gap != nil {
if saveErr := repository.SaveCapabilityGap(ctx, *resolution.Gap); saveErr != nil {
return capability.CapabilityResolution{}, saveErr
}
return resolution, err
}
return resolution, err
}
func observeCapabilityUsage(ctx context.Context, repository *postgres.CapabilityRepository, selected capability.ResolvedCapabilityImplementation, requirement capability.CapabilityResolutionRequirement, outcome capability.UsageOutcomeClass, outcomeRef string, started time.Time, now time.Time) (capability.CapabilityUsageObservationID, error) {
id, err := ids.New()
if err != nil {
return "", err
}
latency := now.Sub(started).Milliseconds()
observation := capability.CapabilityUsageObservation{
ID: capability.CapabilityUsageObservationID(id),
ImplementationID: selected.Implementation.ID,
ImplementationRevision: selected.Implementation.Revision,
CapabilityID: selected.Implementation.CapabilityID,
CapabilityKind: selected.Implementation.CapabilityKind,
OwnerPrincipalID: selected.Implementation.OwnerPrincipalID,
OriginalIntentID: requirement.OriginalIntentID,
SourceUtteranceID: requirement.SourceUtteranceID,
Outcome: outcome,
OutcomeRef: outcomeRef,
LatencyMillis: &latency,
ObservedAtUnixMillis: now.UnixMilli(),
}
if err := repository.SaveCapabilityUsageObservation(ctx, observation); err != nil {
return "", err
}
return observation.ID, nil
}
func buildReminderRequest(message string, session identity.CreatorSession, intent interaction.OriginalHumanIntent, utterance interaction.HumanUtterance, now time.Time) (continuity.ScheduleReminderRequest, error) {
trigger, err := parseReminderTime(message, now)
if err != nil {
return continuity.ScheduleReminderRequest{}, err
}
summary := reminderSummary(message)
if summary == "" {
summary = strings.TrimSpace(message)
}
return continuity.ScheduleReminderRequest{
OwnerPrincipalID: session.PrincipalID,
ScopeID: authority.ScopeID("domovoy-global-scope"),
SourceIntentID: string(intent.ID),
SourceUtteranceID: string(utterance.ID),
Summary: summary,
TriggerAtUnixMillis: trigger.UnixMilli(),
RequestedAtUnixMillis: now.UnixMilli(),
}, nil
}
func parseReminderTime(message string, now time.Time) (time.Time, error) {
lower := strings.ToLower(message)
relative := regexp.MustCompile(`через\s+(\d{1,3})\s*(минуту|минуты|минут|час|часа|часов)`)
if match := relative.FindStringSubmatch(lower); len(match) > 0 {
value, err := strconv.Atoi(match[1])
if err != nil || value < 1 {
return time.Time{}, fmt.Errorf("invalid relative reminder time")
}
unit := match[2]
if strings.HasPrefix(unit, "час") {
if value > 72 {
return time.Time{}, fmt.Errorf("relative reminder time is too far")
}
return now.Add(time.Duration(value) * time.Hour), nil
}
if value > 24*60 {
return time.Time{}, fmt.Errorf("relative reminder time is too far")
}
return now.Add(time.Duration(value) * time.Minute), nil
}
day := now
if strings.Contains(lower, "послезавтра") {
day = now.AddDate(0, 0, 2)
} else if strings.Contains(lower, "завтра") {
day = now.AddDate(0, 0, 1)
}
hour, minute := 9, 0
re := regexp.MustCompile(`(?:в|на)\s+([0-2]?\d)(?::([0-5]\d))?`)
if match := re.FindStringSubmatch(lower); len(match) > 0 {
parsedHour, err := strconv.Atoi(match[1])
if err != nil || parsedHour > 23 {
return time.Time{}, fmt.Errorf("invalid reminder hour")
}
hour = parsedHour
if match[2] != "" {
parsedMinute, err := strconv.Atoi(match[2])
if err != nil {
return time.Time{}, fmt.Errorf("invalid reminder minute")
}
minute = parsedMinute
}
}
trigger := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, 0, 0, now.Location())
if !trigger.After(now) {
trigger = trigger.AddDate(0, 0, 1)
}
return trigger, nil
}
func reminderSummary(message string) string {
clean := strings.TrimSpace(message)
lower := strings.ToLower(clean)
for _, prefix := range []string{"напомни мне", "напомни"} {
if strings.HasPrefix(lower, prefix) {
clean = strings.TrimSpace(clean[len(prefix):])
lower = strings.ToLower(clean)
break
}
}
for _, word := range []string{"послезавтра", "завтра", "сегодня"} {
clean = strings.TrimSpace(strings.ReplaceAll(strings.ToLower(clean), word, ""))
}
relative := regexp.MustCompile(`(?i)через\s+\d{1,3}\s*(минуту|минуты|минут|час|часа|часов)`)
clean = strings.TrimSpace(relative.ReplaceAllString(clean, ""))
re := regexp.MustCompile(`(?i)(в|на)\s+[0-2]?\d(?::[0-5]\d)?`)
clean = strings.TrimSpace(re.ReplaceAllString(clean, ""))
clean = strings.Trim(clean, " .,!?:;")
return clean
}