213 lines
8.4 KiB
Go
213 lines
8.4 KiB
Go
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 || operation == contract.ResponseContinue || operation == contract.ResponseReject {
|
|
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
|
|
} else {
|
|
response.TargetKind = contract.ResponseTargetProspectiveItem
|
|
response.TargetProspectiveID = candidate.Prospective.ID
|
|
}
|
|
response.ResolutionState = contract.ResponseResolved
|
|
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
|
|
}
|