Compare commits

...
Author SHA1 Message Date
m 1e554f6758 DEV-000066 widen response candidate resolution
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 16s
2026-08-17 17:33:05 +03:00
m 1c7d512710 Promote DEV-000066 prospective response summary resolution
CI / test (push) Successful in 16s
DEV-000066 prospective response summary resolution follow-up
2026-08-17 14:21:41 +00:00
m 8c6a7bebbd DEV-000066 resolve prospective responses by summary
CI / test (push) Successful in 17s
CI / test (pull_request) Successful in 14s
2026-08-17 17:20:44 +03:00
m 36774b37ae Promote DEV-000066 pre-delivery reminder responses
CI / test (push) Successful in 15s
DEV-000066 pre-delivery reminder response follow-up
2026-08-17 14:17:39 +00:00
m c79de1c757 DEV-000066 support pre-delivery reminder responses
CI / test (push) Successful in 20s
CI / test (pull_request) Successful in 19s
2026-08-17 17:16:29 +03:00
m acf012720f Promote DEV-000066 natural target resolution
CI / test (push) Successful in 15s
DEV-000066 natural target resolution follow-up
2026-08-17 14:11:51 +00:00
m ccf790d130 DEV-000066 natural response target resolution
CI / test (push) Successful in 17s
CI / test (pull_request) Successful in 16s
2026-08-17 17:11:10 +03:00
m 54c2cc27a3 Promote DEV-000066 human response continuation semantics
CI / test (push) Successful in 15s
DEV-000066 Human Response + Continuation Semantics V1
2026-08-17 14:04:11 +00:00
4 changed files with 141 additions and 25 deletions
+56 -21
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"git.cin.su/m/Assistant/contracts/authority"
"git.cin.su/m/Assistant/contracts/continuity" "git.cin.su/m/Assistant/contracts/continuity"
"git.cin.su/m/Assistant/contracts/identity" "git.cin.su/m/Assistant/contracts/identity"
continuitydomain "git.cin.su/m/Assistant/modules/continuity/domain" continuitydomain "git.cin.su/m/Assistant/modules/continuity/domain"
@@ -213,21 +214,20 @@ func (r *ContinuityRepository) AttentionByOwner(ctx context.Context, owner ident
} }
func (r *ContinuityRepository) ResponseCandidates(ctx context.Context, owner identity.PrincipalID, limit int) ([]continuitydomain.ResponseCandidate, error) { func (r *ContinuityRepository) ResponseCandidates(ctx context.Context, owner identity.PrincipalID, limit int) ([]continuitydomain.ResponseCandidate, error) {
if owner == "" || limit < 1 || limit > 10 { if owner == "" || limit < 1 || limit > 50 {
return nil, fmt.Errorf("invalid response candidate query") return nil, fmt.Errorf("invalid response candidate query")
} }
rows, err := r.pool.Query(ctx, `SELECT rows, err := r.pool.Query(ctx, `SELECT
a.attention_item_id,a.owner_principal_id,a.scope_id,a.source_kind,a.source_prospective_item_id,a.level,a.state,a.summary, COALESCE(a.attention_item_id,''),COALESCE(a.owner_principal_id,''),COALESCE(a.scope_id,''),COALESCE(a.source_kind,''),COALESCE(a.source_prospective_item_id,''),COALESCE(a.level,''),COALESCE(a.state,''),COALESCE(a.summary,''),
a.due_at_unix_millis,a.created_at_unix_millis,a.delivered_at_unix_millis,a.action_intent_id,a.authorized_action_id,a.action_attempt_id,a.delivery_outcome, COALESCE(a.due_at_unix_millis,0),COALESCE(a.created_at_unix_millis,0),COALESCE(a.delivered_at_unix_millis,0),COALESCE(a.action_intent_id,''),COALESCE(a.authorized_action_id,''),COALESCE(a.action_attempt_id,''),COALESCE(a.delivery_outcome,''),
a.response_id,a.response_utterance_id,a.response_operation,a.response_outcome,a.responded_at_unix_millis,a.revision, COALESCE(a.response_id,''),COALESCE(a.response_utterance_id,''),COALESCE(a.response_operation,''),COALESCE(a.response_outcome,''),COALESCE(a.responded_at_unix_millis,0),COALESCE(a.revision,0),
p.prospective_item_id,p.commitment_id,p.owner_principal_id,p.scope_id,p.goal_id,p.source_intent_id,p.source_utterance_id,p.summary,p.trigger_at_unix_millis,p.state,p.revision p.prospective_item_id,p.commitment_id,p.owner_principal_id,p.scope_id,p.goal_id,p.source_intent_id,p.source_utterance_id,p.summary,p.trigger_at_unix_millis,p.state,p.revision
FROM continuity.attention_items a FROM continuity.prospective_items p
JOIN continuity.prospective_items p ON p.prospective_item_id=a.source_prospective_item_id LEFT JOIN continuity.attention_items a ON a.source_kind=$2 AND a.source_prospective_item_id=p.prospective_item_id AND a.owner_principal_id=p.owner_principal_id
WHERE a.owner_principal_id=$1 WHERE p.owner_principal_id=$1
AND a.source_kind=$2 AND (a.attention_item_id IS NULL OR a.state IN ($3,$4,$5))
AND a.state IN ($3,$4,$5)
AND p.state IN ($6,$7) AND p.state IN ($6,$7)
ORDER BY a.delivered_at_unix_millis DESC, a.due_at_unix_millis DESC, a.attention_item_id DESC ORDER BY COALESCE(a.delivered_at_unix_millis,0) DESC, p.trigger_at_unix_millis DESC, p.prospective_item_id DESC
LIMIT $8`, LIMIT $8`,
owner, continuity.AttentionSourceProspectiveReminder, continuity.AttentionDelivered, continuity.AttentionPending, owner, continuity.AttentionSourceProspectiveReminder, continuity.AttentionDelivered, continuity.AttentionPending,
continuity.AttentionFailed, continuity.ProspectiveDelivered, continuity.ProspectiveArmed, limit) continuity.AttentionFailed, continuity.ProspectiveDelivered, continuity.ProspectiveArmed, limit)
@@ -238,20 +238,45 @@ func (r *ContinuityRepository) ResponseCandidates(ctx context.Context, owner ide
var candidates []continuitydomain.ResponseCandidate var candidates []continuitydomain.ResponseCandidate
for rows.Next() { for rows.Next() {
var candidate continuitydomain.ResponseCandidate var candidate continuitydomain.ResponseCandidate
var attentionID, attentionOwner, attentionScope, sourceKind, sourceProspective, level, attentionState, attentionSummary string
var actionIntent, authorizedAction, actionAttempt, deliveryOutcome, responseID, responseUtterance, responseOperation, responseOutcome string
var dueAt, createdAt, deliveredAt, respondedAt int64
var attentionRevision uint64
if err := rows.Scan( if err := rows.Scan(
&candidate.Attention.ID, &candidate.Attention.OwnerPrincipalID, &candidate.Attention.ScopeID, &candidate.Attention.SourceKind, &attentionID, &attentionOwner, &attentionScope, &sourceKind, &sourceProspective, &level, &attentionState, &attentionSummary,
&candidate.Attention.SourceProspectiveID, &candidate.Attention.Level, &candidate.Attention.State, &candidate.Attention.Summary, &dueAt, &createdAt, &deliveredAt, &actionIntent, &authorizedAction, &actionAttempt, &deliveryOutcome,
&candidate.Attention.DueAtUnixMillis, &candidate.Attention.CreatedAtUnixMillis, &candidate.Attention.DeliveredAtUnixMillis, &responseID, &responseUtterance, &responseOperation, &responseOutcome, &respondedAt, &attentionRevision,
&candidate.Attention.ActionIntentID, &candidate.Attention.AuthorizedActionID, &candidate.Attention.ActionAttemptID,
&candidate.Attention.DeliveryOutcome, &candidate.Attention.ResponseID, &candidate.Attention.ResponseUtteranceID,
&candidate.Attention.ResponseOperation, &candidate.Attention.ResponseOutcome, &candidate.Attention.RespondedAtUnixMillis,
&candidate.Attention.Revision,
&candidate.Prospective.ID, &candidate.Prospective.CommitmentID, &candidate.Prospective.OwnerPrincipalID, &candidate.Prospective.ID, &candidate.Prospective.CommitmentID, &candidate.Prospective.OwnerPrincipalID,
&candidate.Prospective.ScopeID, &candidate.Prospective.GoalID, &candidate.Prospective.SourceIntentID, &candidate.Prospective.ScopeID, &candidate.Prospective.GoalID, &candidate.Prospective.SourceIntentID,
&candidate.Prospective.SourceUtteranceID, &candidate.Prospective.Summary, &candidate.Prospective.TriggerAtUnixMillis, &candidate.Prospective.SourceUtteranceID, &candidate.Prospective.Summary, &candidate.Prospective.TriggerAtUnixMillis,
&candidate.Prospective.State, &candidate.Prospective.Revision); err != nil { &candidate.Prospective.State, &candidate.Prospective.Revision); err != nil {
return nil, fmt.Errorf("scan human response candidate: %w", err) return nil, fmt.Errorf("scan human response candidate: %w", err)
} }
if attentionID != "" {
candidate.Attention = continuity.AttentionItem{
ID: continuity.AttentionItemID(attentionID),
OwnerPrincipalID: identity.PrincipalID(attentionOwner),
ScopeID: authority.ScopeID(attentionScope),
SourceKind: continuity.AttentionSourceKind(sourceKind),
SourceProspectiveID: continuity.ProspectiveItemID(sourceProspective),
Level: continuity.AttentionLevel(level),
State: continuity.AttentionState(attentionState),
Summary: attentionSummary,
DueAtUnixMillis: dueAt,
CreatedAtUnixMillis: createdAt,
DeliveredAtUnixMillis: deliveredAt,
ActionIntentID: continuity.CommittedActionIntentID(actionIntent),
AuthorizedActionID: continuity.CommittedAuthorizedActionID(authorizedAction),
ActionAttemptID: continuity.CommittedActionAttemptID(actionAttempt),
DeliveryOutcome: deliveryOutcome,
ResponseID: continuity.HumanResponseID(responseID),
ResponseUtteranceID: responseUtterance,
ResponseOperation: continuity.HumanResponseOperation(responseOperation),
ResponseOutcome: continuity.ResponseOutcome(responseOutcome),
RespondedAtUnixMillis: respondedAt,
Revision: continuity.AttentionRevision(attentionRevision),
}
}
candidates = append(candidates, candidate) candidates = append(candidates, candidate)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@@ -292,6 +317,9 @@ func (r *ContinuityRepository) ApplyHumanResponse(ctx context.Context, response
if tag.RowsAffected() != 1 { if tag.RowsAffected() != 1 {
return ErrProspectiveStateConflict return ErrProspectiveStateConflict
} }
if response.TargetAttentionID == "" {
return ErrAttentionNotFound
}
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionResolved); err != nil { if err = markAttentionResponded(ctx, tx, response, continuity.AttentionResolved); err != nil {
return err return err
} }
@@ -306,8 +334,10 @@ func (r *ContinuityRepository) ApplyHumanResponse(ctx context.Context, response
if tag.RowsAffected() != 1 { if tag.RowsAffected() != 1 {
return ErrProspectiveStateConflict return ErrProspectiveStateConflict
} }
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionResolved); err != nil { if response.TargetAttentionID != "" {
return err if err = markAttentionResponded(ctx, tx, response, continuity.AttentionResolved); err != nil {
return err
}
} }
case continuity.ResponseCancel: case continuity.ResponseCancel:
tag, err := tx.Exec(ctx, `UPDATE continuity.prospective_items SET state=$1, revision=revision+1 tag, err := tx.Exec(ctx, `UPDATE continuity.prospective_items SET state=$1, revision=revision+1
@@ -320,10 +350,15 @@ func (r *ContinuityRepository) ApplyHumanResponse(ctx context.Context, response
if tag.RowsAffected() != 1 { if tag.RowsAffected() != 1 {
return ErrProspectiveStateConflict return ErrProspectiveStateConflict
} }
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionSuppressed); err != nil { if response.TargetAttentionID != "" {
return err if err = markAttentionResponded(ctx, tx, response, continuity.AttentionSuppressed); err != nil {
return err
}
} }
case continuity.ResponseAcknowledge, continuity.ResponseDismiss: case continuity.ResponseAcknowledge, continuity.ResponseDismiss:
if response.TargetAttentionID == "" {
return ErrAttentionNotFound
}
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionResolved); err != nil { if err = markAttentionResponded(ctx, tx, response, continuity.AttentionResolved); err != nil {
return err return err
} }
+1 -1
View File
@@ -42,7 +42,7 @@ func registerCreatorResponseRoute(mux *http.ServeMux, identityClient creatorIden
http.Error(w, "human response unavailable", http.StatusServiceUnavailable) http.Error(w, "human response unavailable", http.StatusServiceUnavailable)
return return
} }
candidates, err := repository.ResponseCandidates(r.Context(), session.PrincipalID, 3) candidates, err := repository.ResponseCandidates(r.Context(), session.PrincipalID, 20)
if err != nil { if err != nil {
http.Error(w, "response candidates unavailable", http.StatusInternalServerError) http.Error(w, "response candidates unavailable", http.StatusInternalServerError)
return return
+45 -3
View File
@@ -50,6 +50,12 @@ func InterpretHumanResponse(request contract.HumanResponseRequest, owner identit
response.Clarification = "Я не вижу активного ожидания, к которому это относится." response.Clarification = "Я не вижу активного ожидания, к которому это относится."
return contract.HumanResponseResult{Response: response, Handled: true, Reply: response.Clarification}, nil 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 { if len(candidates) > 1 {
response.ResolutionState = contract.ResponseAmbiguous response.ResolutionState = contract.ResponseAmbiguous
response.Outcome = contract.ResponseOutcomeClarificationNeeded response.Outcome = contract.ResponseOutcomeClarificationNeeded
@@ -57,9 +63,14 @@ func InterpretHumanResponse(request contract.HumanResponseRequest, owner identit
return contract.HumanResponseResult{Response: response, Handled: true, Reply: response.Clarification}, nil return contract.HumanResponseResult{Response: response, Handled: true, Reply: response.Clarification}, nil
} }
candidate := candidates[0] candidate := candidates[0]
response.TargetKind = contract.ResponseTargetAttentionItem if candidate.Attention.ID != "" {
response.TargetAttentionID = candidate.Attention.ID response.TargetKind = contract.ResponseTargetAttentionItem
response.TargetProspectiveID = candidate.Attention.SourceProspectiveID response.TargetAttentionID = candidate.Attention.ID
response.TargetProspectiveID = candidate.Attention.SourceProspectiveID
} else {
response.TargetKind = contract.ResponseTargetProspectiveItem
response.TargetProspectiveID = candidate.Prospective.ID
}
response.ResolutionState = contract.ResponseResolved response.ResolutionState = contract.ResponseResolved
switch operation { switch operation {
case contract.ResponseComplete: case contract.ResponseComplete:
@@ -142,6 +153,9 @@ func candidateSummary(candidates []ResponseCandidate) string {
parts := make([]string, 0, len(candidates)) parts := make([]string, 0, len(candidates))
for _, candidate := range candidates { for _, candidate := range candidates {
text := strings.TrimSpace(candidate.Attention.Summary) text := strings.TrimSpace(candidate.Attention.Summary)
if text == "" {
text = strings.TrimSpace(candidate.Prospective.Summary)
}
if text == "" { if text == "" {
text = "ожидание" text = "ожидание"
} }
@@ -153,6 +167,34 @@ func candidateSummary(candidates []ResponseCandidate) string {
return strings.Join(parts, "; ") 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 { func humanResponseTime(value time.Time) string {
if value.IsZero() { if value.IsZero() {
return "позже" return "позже"
@@ -34,6 +34,38 @@ func TestInterpretHumanResponseCancelsSingleReminder(t *testing.T) {
} }
} }
func TestInterpretHumanResponseCancelsArmedProspectiveBeforeAttention(t *testing.T) {
result := interpretResponseForTest(t, "Отмени встретить курьера", []ResponseCandidate{{
Prospective: continuity.ProspectiveItem{ID: "p1", OwnerPrincipalID: "principal-1", ScopeID: "scope-1", Summary: "встретить курьера", State: continuity.ProspectiveArmed, Revision: 1},
}})
if !result.Handled || result.Response.TargetKind != continuity.ResponseTargetProspectiveItem || result.Response.TargetProspectiveID != "p1" || result.Response.Outcome != continuity.ResponseOutcomeCancelled {
t.Fatalf("unexpected prospective cancel response: %#v", result)
}
}
func TestInterpretHumanResponseResolvesArmedProspectiveByNaturalSummaryReference(t *testing.T) {
result := interpretResponseForTest(t, "Отмени встретить курьера", []ResponseCandidate{
{Prospective: continuity.ProspectiveItem{ID: "p1", OwnerPrincipalID: "principal-1", ScopeID: "scope-1", Summary: "позвонить врачу", State: continuity.ProspectiveArmed, Revision: 1}},
{Prospective: continuity.ProspectiveItem{ID: "p2", OwnerPrincipalID: "principal-1", ScopeID: "scope-1", Summary: "встретить курьера", State: continuity.ProspectiveArmed, Revision: 1}},
})
if !result.Handled || result.Response.TargetKind != continuity.ResponseTargetProspectiveItem || result.Response.TargetProspectiveID != "p2" || result.Response.Outcome != continuity.ResponseOutcomeCancelled {
t.Fatalf("unexpected prospective natural target response: %#v", result)
}
}
func TestInterpretHumanResponseResolvesSummaryReferenceBeyondFirstThreeCandidates(t *testing.T) {
candidates := []ResponseCandidate{
responseCandidate("a1", "p1", "первое ожидание"),
responseCandidate("a2", "p2", "второе ожидание"),
responseCandidate("a3", "p3", "третье ожидание"),
{Prospective: continuity.ProspectiveItem{ID: "p4", OwnerPrincipalID: "principal-1", ScopeID: "scope-1", Summary: "встретить курьера", State: continuity.ProspectiveArmed, Revision: 1}},
}
result := interpretResponseForTest(t, "Отмени встретить курьера", candidates)
if !result.Handled || result.Response.ResolutionState != continuity.ResponseResolved || result.Response.TargetProspectiveID != "p4" {
t.Fatalf("expected target resolution beyond first three candidates, got %#v", result)
}
}
func TestInterpretHumanResponseAmbiguousTargetAsksClarificationWithoutResolvedMutation(t *testing.T) { func TestInterpretHumanResponseAmbiguousTargetAsksClarificationWithoutResolvedMutation(t *testing.T) {
result := interpretResponseForTest(t, "Готово", []ResponseCandidate{responseCandidate("a1", "p1", "первое"), responseCandidate("a2", "p2", "второе")}) result := interpretResponseForTest(t, "Готово", []ResponseCandidate{responseCandidate("a1", "p1", "первое"), responseCandidate("a2", "p2", "второе")})
if !result.Handled || result.Response.ResolutionState != continuity.ResponseAmbiguous || result.Response.Outcome != continuity.ResponseOutcomeClarificationNeeded || !strings.Contains(result.Reply, "несколько") { if !result.Handled || result.Response.ResolutionState != continuity.ResponseAmbiguous || result.Response.Outcome != continuity.ResponseOutcomeClarificationNeeded || !strings.Contains(result.Reply, "несколько") {
@@ -41,6 +73,13 @@ func TestInterpretHumanResponseAmbiguousTargetAsksClarificationWithoutResolvedMu
} }
} }
func TestInterpretHumanResponseResolvesAmbiguousTargetByNaturalSummaryReference(t *testing.T) {
result := interpretResponseForTest(t, "Готово, проверить базу сделано", []ResponseCandidate{responseCandidate("a1", "p1", "позвонить врачу"), responseCandidate("a2", "p2", "проверить базу")})
if !result.Handled || result.Response.ResolutionState != continuity.ResponseResolved || result.Response.TargetAttentionID != "a2" {
t.Fatalf("expected natural summary target resolution, got %#v", result)
}
}
func TestInterpretHumanResponseNoTargetAsksClarification(t *testing.T) { func TestInterpretHumanResponseNoTargetAsksClarification(t *testing.T) {
result := interpretResponseForTest(t, "Готово", nil) result := interpretResponseForTest(t, "Готово", nil)
if !result.Handled || result.Response.ResolutionState != continuity.ResponseNoTarget || result.Response.Outcome != continuity.ResponseOutcomeClarificationNeeded { if !result.Handled || result.Response.ResolutionState != continuity.ResponseNoTarget || result.Response.Outcome != continuity.ResponseOutcomeClarificationNeeded {