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
4 changed files with 105 additions and 26 deletions
+56 -21
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"git.cin.su/m/Assistant/contracts/authority"
"git.cin.su/m/Assistant/contracts/continuity"
"git.cin.su/m/Assistant/contracts/identity"
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) {
if owner == "" || limit < 1 || limit > 10 {
if owner == "" || limit < 1 || limit > 50 {
return nil, fmt.Errorf("invalid response candidate query")
}
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,
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,
a.response_id,a.response_utterance_id,a.response_operation,a.response_outcome,a.responded_at_unix_millis,a.revision,
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,''),
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,''),
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
FROM continuity.attention_items a
JOIN continuity.prospective_items p ON p.prospective_item_id=a.source_prospective_item_id
WHERE a.owner_principal_id=$1
AND a.source_kind=$2
AND a.state IN ($3,$4,$5)
FROM continuity.prospective_items p
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 p.owner_principal_id=$1
AND (a.attention_item_id IS NULL OR a.state IN ($3,$4,$5))
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`,
owner, continuity.AttentionSourceProspectiveReminder, continuity.AttentionDelivered, continuity.AttentionPending,
continuity.AttentionFailed, continuity.ProspectiveDelivered, continuity.ProspectiveArmed, limit)
@@ -238,20 +238,45 @@ func (r *ContinuityRepository) ResponseCandidates(ctx context.Context, owner ide
var candidates []continuitydomain.ResponseCandidate
for rows.Next() {
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(
&candidate.Attention.ID, &candidate.Attention.OwnerPrincipalID, &candidate.Attention.ScopeID, &candidate.Attention.SourceKind,
&candidate.Attention.SourceProspectiveID, &candidate.Attention.Level, &candidate.Attention.State, &candidate.Attention.Summary,
&candidate.Attention.DueAtUnixMillis, &candidate.Attention.CreatedAtUnixMillis, &candidate.Attention.DeliveredAtUnixMillis,
&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,
&attentionID, &attentionOwner, &attentionScope, &sourceKind, &sourceProspective, &level, &attentionState, &attentionSummary,
&dueAt, &createdAt, &deliveredAt, &actionIntent, &authorizedAction, &actionAttempt, &deliveryOutcome,
&responseID, &responseUtterance, &responseOperation, &responseOutcome, &respondedAt, &attentionRevision,
&candidate.Prospective.ID, &candidate.Prospective.CommitmentID, &candidate.Prospective.OwnerPrincipalID,
&candidate.Prospective.ScopeID, &candidate.Prospective.GoalID, &candidate.Prospective.SourceIntentID,
&candidate.Prospective.SourceUtteranceID, &candidate.Prospective.Summary, &candidate.Prospective.TriggerAtUnixMillis,
&candidate.Prospective.State, &candidate.Prospective.Revision); err != nil {
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)
}
if err := rows.Err(); err != nil {
@@ -292,6 +317,9 @@ func (r *ContinuityRepository) ApplyHumanResponse(ctx context.Context, response
if tag.RowsAffected() != 1 {
return ErrProspectiveStateConflict
}
if response.TargetAttentionID == "" {
return ErrAttentionNotFound
}
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionResolved); err != nil {
return err
}
@@ -306,8 +334,10 @@ func (r *ContinuityRepository) ApplyHumanResponse(ctx context.Context, response
if tag.RowsAffected() != 1 {
return ErrProspectiveStateConflict
}
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionResolved); err != nil {
return err
if response.TargetAttentionID != "" {
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionResolved); err != nil {
return err
}
}
case continuity.ResponseCancel:
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 {
return ErrProspectiveStateConflict
}
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionSuppressed); err != nil {
return err
if response.TargetAttentionID != "" {
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionSuppressed); err != nil {
return err
}
}
case continuity.ResponseAcknowledge, continuity.ResponseDismiss:
if response.TargetAttentionID == "" {
return ErrAttentionNotFound
}
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionResolved); err != nil {
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)
return
}
candidates, err := repository.ResponseCandidates(r.Context(), session.PrincipalID, 3)
candidates, err := repository.ResponseCandidates(r.Context(), session.PrincipalID, 20)
if err != nil {
http.Error(w, "response candidates unavailable", http.StatusInternalServerError)
return
+16 -4
View File
@@ -63,9 +63,14 @@ func InterpretHumanResponse(request contract.HumanResponseRequest, owner identit
return contract.HumanResponseResult{Response: response, Handled: true, Reply: response.Clarification}, nil
}
candidate := candidates[0]
response.TargetKind = contract.ResponseTargetAttentionItem
response.TargetAttentionID = candidate.Attention.ID
response.TargetProspectiveID = candidate.Attention.SourceProspectiveID
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:
@@ -148,6 +153,9 @@ 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 = "ожидание"
}
@@ -163,7 +171,11 @@ func matchingCandidates(message string, candidates []ResponseCandidate) []Respon
lower := strings.ToLower(message)
var matched []ResponseCandidate
for _, candidate := range candidates {
if summaryMatches(lower, candidate.Attention.Summary) {
summary := candidate.Attention.Summary
if summary == "" {
summary = candidate.Prospective.Summary
}
if summaryMatches(lower, summary) {
matched = append(matched, candidate)
}
}
@@ -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) {
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, "несколько") {