Compare commits

...
Author SHA1 Message Date
m 465a90b25d DEV-000066 prefer recent response candidates
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 16s
2026-08-17 17:37:20 +03:00
m 95d6ad2d6a Promote DEV-000066 response candidate window
CI / test (push) Successful in 19s
DEV-000066 response candidate window follow-up
2026-08-17 14:33:57 +00:00
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"
"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 p.recorded_at DESC, COALESCE(a.delivered_at_unix_millis,0) 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
+45 -3
View File
@@ -50,6 +50,12 @@ func InterpretHumanResponse(request contract.HumanResponseRequest, owner identit
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
@@ -57,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:
@@ -142,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 = "ожидание"
}
@@ -153,6 +167,34 @@ func candidateSummary(candidates []ResponseCandidate) string {
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 "позже"
@@ -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, "несколько") {
@@ -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) {
result := interpretResponseForTest(t, "Готово", nil)
if !result.Handled || result.Response.ResolutionState != continuity.ResponseNoTarget || result.Response.Outcome != continuity.ResponseOutcomeClarificationNeeded {