388 lines
15 KiB
Go
388 lines
15 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"
|
|
"git.cin.su/m/Assistant/contracts/work"
|
|
capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain"
|
|
workdomain "git.cin.su/m/Assistant/modules/work/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
|
|
}
|
|
|
|
type continuityAttentionClient 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 newContinuityAttentionClient(rawURL string, client *http.Client) (continuityAttentionClient, error) {
|
|
base, err := url.Parse(rawURL)
|
|
if err != nil || base.Scheme == "" || base.Host == "" {
|
|
return continuityAttentionClient{}, fmt.Errorf("invalid Continuity Runtime URL")
|
|
}
|
|
if client == nil {
|
|
client = http.DefaultClient
|
|
}
|
|
return continuityAttentionClient{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 (c continuityAttentionClient) Create(ctx context.Context, authorization string, request continuity.CreateAttentionRequest) (continuity.AttentionItem, error) {
|
|
target := *c.baseURL
|
|
target.Path = "/v1/creator/attention"
|
|
payload, err := json.Marshal(request)
|
|
if err != nil {
|
|
return continuity.AttentionItem{}, err
|
|
}
|
|
upstream, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
|
|
if err != nil {
|
|
return continuity.AttentionItem{}, err
|
|
}
|
|
upstream.Header.Set("Content-Type", "application/json")
|
|
upstream.Header.Set("Authorization", authorization)
|
|
response, err := c.client.Do(upstream)
|
|
if err != nil {
|
|
return continuity.AttentionItem{}, err
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != http.StatusCreated {
|
|
return continuity.AttentionItem{}, fmt.Errorf("continuity attention status %d", response.StatusCode)
|
|
}
|
|
var result continuity.AttentionItem
|
|
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
|
return continuity.AttentionItem{}, err
|
|
}
|
|
if result.ID == "" || result.SourceKind == "" {
|
|
return continuity.AttentionItem{}, fmt.Errorf("invalid attention result")
|
|
}
|
|
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},
|
|
AllowCanary: allowCanaryForChatCapability(kind),
|
|
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 allowCanaryForChatCapability(kind capability.CapabilityKind) bool {
|
|
switch kind {
|
|
case capability.CapabilityKindModifyCode, capability.CapabilityKindRunTests, capability.CapabilityKindInspectProject:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
type createdDelegation struct {
|
|
Delegation work.Delegation
|
|
Run work.AgentRun
|
|
}
|
|
|
|
func maybeCreateAgentDelegation(ctx context.Context, workRepository *postgres.WorkRepository, capabilityRepository *postgres.CapabilityRepository, session identity.CreatorSession, conversation interaction.Conversation, intent interaction.OriginalHumanIntent, utterance interaction.HumanUtterance, planning work.PlanningResult, now time.Time) (*createdDelegation, capability.CapabilityResolution, error) {
|
|
requirement, ok := primarySoftwareRequirement(planning.Candidate.Requirements)
|
|
if !ok {
|
|
return nil, capability.CapabilityResolution{}, nil
|
|
}
|
|
resolution, err := resolveChatCapability(ctx, capabilityRepository, requirement.CapabilityKind, session, intent, utterance, now)
|
|
if err != nil {
|
|
return nil, resolution, err
|
|
}
|
|
if len(resolution.Eligible) == 0 || resolution.Eligible[0].Implementation.Kind != capability.ImplementationAgent {
|
|
return nil, resolution, capabilitydomain.ErrNoCapabilityImplementation
|
|
}
|
|
started, err := workdomain.StartDelegation(workdomain.DelegationRequest{
|
|
Goal: planning.Goal,
|
|
Plan: planning.Plan,
|
|
Candidate: planning.Candidate,
|
|
Requirement: requirement,
|
|
Implementation: resolution.Eligible[0].Implementation,
|
|
ConversationID: work.ConversationRef(conversation.ID),
|
|
OriginalIntentText: intent.Text,
|
|
ContextSummary: planning.Candidate.Summary,
|
|
ContextRefs: []string{"interaction:intent:" + string(intent.ID), "interaction:utterance:" + string(utterance.ID), "work:plan:" + string(planning.Plan.ID)},
|
|
VerificationSummary: "verify through changed files, relevant tests, and explicit run evidence before work completion",
|
|
DerivedInstruction: buildSoftwareAgentInstruction(planning, intent, utterance),
|
|
StartedAt: now,
|
|
})
|
|
if err != nil {
|
|
return nil, capability.CapabilityResolution{}, err
|
|
}
|
|
if err := workRepository.SaveDelegationBundle(ctx, started.Delegation, started.Run); err != nil {
|
|
return nil, capability.CapabilityResolution{}, err
|
|
}
|
|
return &createdDelegation{Delegation: started.Delegation, Run: started.Run}, resolution, nil
|
|
}
|
|
|
|
func primarySoftwareRequirement(values []work.CapabilityRequirement) (work.CapabilityRequirement, bool) {
|
|
for _, requirement := range values {
|
|
switch requirement.CapabilityKind {
|
|
case capability.CapabilityKindModifyCode, capability.CapabilityKindRunTests, capability.CapabilityKindInspectProject:
|
|
return requirement, true
|
|
}
|
|
}
|
|
return work.CapabilityRequirement{}, false
|
|
}
|
|
|
|
func buildSoftwareAgentInstruction(planning work.PlanningResult, intent interaction.OriginalHumanIntent, utterance interaction.HumanUtterance) string {
|
|
steps := make([]string, 0, len(planning.Candidate.Steps))
|
|
for _, step := range planning.Candidate.Steps {
|
|
if step.Description != "" {
|
|
steps = append(steps, step.Description)
|
|
}
|
|
}
|
|
return strings.TrimSpace(strings.Join([]string{
|
|
"Human asked: " + strings.TrimSpace(intent.Text),
|
|
"Original utterance: " + strings.TrimSpace(utterance.Content),
|
|
"Desired outcome: " + planning.Goal.DesiredOutcome,
|
|
"Plan summary: " + planning.Candidate.Summary,
|
|
"Verification: run relevant tests and keep explicit evidence before claiming completion.",
|
|
"Steps: " + strings.Join(steps, " | "),
|
|
}, "\n"))
|
|
}
|
|
|
|
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
|
|
}
|