package domain import ( "errors" "fmt" "regexp" "strconv" "strings" "time" "git.cin.su/m/Assistant/contracts/authority" contract "git.cin.su/m/Assistant/contracts/continuity" "git.cin.su/m/Assistant/contracts/identity" ) var ErrInvalidHumanResponse = errors.New("invalid human response") type ResponseCandidate struct { Attention contract.AttentionItem Prospective contract.ProspectiveItem } func InterpretHumanResponse(request contract.HumanResponseRequest, owner identity.PrincipalID, scope authority.ScopeID, id contract.HumanResponseID, candidates []ResponseCandidate, now time.Time) (contract.HumanResponseResult, error) { message := strings.TrimSpace(request.Message) if id == "" || owner == "" || scope == "" || request.SourceUtteranceID == "" || request.SourceIntentID == "" || message == "" || request.RequestedAtUnixMillis < 1 { return contract.HumanResponseResult{}, ErrInvalidHumanResponse } operation, snoozeUntil := detectResponseOperation(message, now) response := contract.HumanResponse{ ID: id, OwnerPrincipalID: owner, ScopeID: scope, SourceUtteranceID: strings.TrimSpace(request.SourceUtteranceID), SourceIntentID: strings.TrimSpace(request.SourceIntentID), Operation: operation, TargetKind: contract.ResponseTargetUnknown, ResolutionState: contract.ResponseNoop, Outcome: contract.ResponseOutcomeNoop, SnoozeUntilUnixMillis: snoozeUntil.UnixMilli(), RecordedAtUnixMillis: now.UnixMilli(), Revision: 1, UserReportedNotVerified: false, } if operation == contract.ResponseUnknown { return contract.HumanResponseResult{Response: response, Handled: false}, nil } if len(candidates) == 0 { response.ResolutionState = contract.ResponseNoTarget response.Outcome = contract.ResponseOutcomeClarificationNeeded response.Clarification = "Я не вижу активного ожидания, к которому это относится." return contract.HumanResponseResult{Response: response, Handled: true, Reply: response.Clarification}, nil } if len(candidates) > 1 { matched := matchingCandidates(message, candidates) if len(matched) == 1 { candidates = matched } } if len(candidates) > 1 { response.ResolutionState = contract.ResponseAmbiguous response.Outcome = contract.ResponseOutcomeClarificationNeeded response.Clarification = "У меня есть несколько возможных ожиданий. Уточните, о каком именно речь: " + candidateSummary(candidates) return contract.HumanResponseResult{Response: response, Handled: true, Reply: response.Clarification}, nil } candidate := candidates[0] if candidate.Attention.ID != "" { response.TargetKind = contract.ResponseTargetAttentionItem response.TargetAttentionID = candidate.Attention.ID response.TargetProspectiveID = candidate.Attention.SourceProspectiveID response.TargetExternalRef = candidate.Attention.SourceExternalRef } else { response.TargetKind = contract.ResponseTargetProspectiveItem response.TargetProspectiveID = candidate.Prospective.ID } response.ResolutionState = contract.ResponseResolved if response.TargetExternalRef != "" && candidate.Attention.SourceKind == contract.AttentionSourceAgentDelegation { switch operation { case contract.ResponseContinue: response.Outcome = contract.ResponseOutcomeContinued return contract.HumanResponseResult{Response: response, Handled: true, Reply: "Принял. Продолжаю работу по этой задаче."}, nil case contract.ResponseReject: response.Outcome = contract.ResponseOutcomeRejected return contract.HumanResponseResult{Response: response, Handled: true, Reply: "Понял. Зафиксировал, что здесь нужно другое решение."}, nil } } switch operation { case contract.ResponseComplete: response.Outcome = contract.ResponseOutcomeUserReportedComplete response.UserReportedNotVerified = true return contract.HumanResponseResult{Response: response, Handled: true, Reply: "Готово, я закрыл это напоминание. Записал как «сообщено человеком», без внешней проверки."}, nil case contract.ResponseSnooze: if response.SnoozeUntilUnixMillis <= now.UnixMilli() { response.ResolutionState = contract.ResponseNoop response.Outcome = contract.ResponseOutcomeClarificationNeeded response.Clarification = "На сколько отложить? Например: «Отложи на 10 минут»." return contract.HumanResponseResult{Response: response, Handled: true, Reply: response.Clarification}, nil } response.Outcome = contract.ResponseOutcomeSnoozed return contract.HumanResponseResult{Response: response, Handled: true, Reply: "Отложил. Напомню " + humanResponseTime(snoozeUntil) + "."}, nil case contract.ResponseCancel: response.Outcome = contract.ResponseOutcomeCancelled return contract.HumanResponseResult{Response: response, Handled: true, Reply: "Отменил. Больше не буду напоминать об этом."}, nil case contract.ResponseAcknowledge, contract.ResponseDismiss: response.Outcome = contract.ResponseOutcomeDismissed return contract.HumanResponseResult{Response: response, Handled: true, Reply: "Принял, убрал это из текущего внимания."}, nil default: response.ResolutionState = contract.ResponseNoop response.Outcome = contract.ResponseOutcomeNoop return contract.HumanResponseResult{Response: response, Handled: false}, nil } } func detectResponseOperation(message string, now time.Time) (contract.HumanResponseOperation, time.Time) { lower := strings.ToLower(strings.TrimSpace(message)) if containsAny(lower, "отложи", "перенеси", "позже", "потом") { if until, ok := parseSnoozeUntil(lower, now); ok { return contract.ResponseSnooze, until } return contract.ResponseSnooze, time.Time{} } if containsAny(lower, "отмени", "отменить", "не надо", "не нужно", "больше не напоминай") { return contract.ResponseCancel, time.Time{} } if containsAny(lower, "готово", "сделал", "сделала", "выполнено", "закрыл", "закрыла") { return contract.ResponseComplete, time.Time{} } if lower == "ок" || lower == "понял" || lower == "поняла" || lower == "принято" { return contract.ResponseAcknowledge, time.Time{} } if containsAny(lower, "продолжай", "дальше") { return contract.ResponseContinue, time.Time{} } if containsAny(lower, "отклоняю", "нет", "не согласен", "не согласна") { return contract.ResponseReject, time.Time{} } return contract.ResponseUnknown, time.Time{} } func parseSnoozeUntil(lower string, now time.Time) (time.Time, bool) { 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{}, false } if strings.HasPrefix(match[2], "час") { if value > 72 { return time.Time{}, false } return now.Add(time.Duration(value) * time.Hour), true } if value > 24*60 { return time.Time{}, false } return now.Add(time.Duration(value) * time.Minute), true } if strings.Contains(lower, "позже") || strings.Contains(lower, "потом") { return now.Add(time.Hour), true } return time.Time{}, false } func candidateSummary(candidates []ResponseCandidate) string { parts := make([]string, 0, len(candidates)) for _, candidate := range candidates { text := strings.TrimSpace(candidate.Attention.Summary) if text == "" { text = strings.TrimSpace(candidate.Prospective.Summary) } if text == "" { text = "ожидание" } parts = append(parts, text) } if len(parts) > 3 { parts = parts[:3] } return strings.Join(parts, "; ") } func matchingCandidates(message string, candidates []ResponseCandidate) []ResponseCandidate { lower := strings.ToLower(message) var matched []ResponseCandidate for _, candidate := range candidates { summary := candidate.Attention.Summary if summary == "" { summary = candidate.Prospective.Summary } if summaryMatches(lower, summary) { matched = append(matched, candidate) } } return matched } func summaryMatches(lowerMessage string, summary string) bool { for _, token := range strings.Fields(strings.ToLower(summary)) { token = strings.Trim(token, ".,!?:;()[]«»\"'") if len([]rune(token)) < 4 { continue } if strings.Contains(lowerMessage, token) { return true } } return false } func humanResponseTime(value time.Time) string { if value.IsZero() { return "позже" } return fmt.Sprintf("%02d.%02d в %02d:%02d", value.Day(), value.Month(), value.Hour(), value.Minute()) } func containsAny(value string, needles ...string) bool { for _, needle := range needles { if strings.Contains(value, needle) { return true } } return false }