Files
Assistant/modules/continuity/domain/human_response.go
m b3491520bd
CI / test (push) Successful in 20s
CI / test (pull_request) Successful in 20s
DEV-000069 general agent delegation codex bridge foundation
2026-08-17 20:07:14 +03:00

224 lines
9.1 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 {
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
}