Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b9b841645 | ||
|
|
e8e51ffdba |
@@ -9,6 +9,7 @@ import (
|
|||||||
"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"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -145,17 +146,33 @@ func (r *ContinuityRepository) ClaimDueReminder(ctx context.Context, id continui
|
|||||||
err = tx.QueryRow(ctx, `INSERT INTO continuity.attention_items (
|
err = tx.QueryRow(ctx, `INSERT INTO continuity.attention_items (
|
||||||
attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,
|
attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,
|
||||||
due_at_unix_millis,created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,
|
due_at_unix_millis,created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,
|
||||||
action_attempt_id,delivery_outcome,revision)
|
action_attempt_id,delivery_outcome,response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,0,'','','','',1)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,0,'','','','','','','','',0,1)
|
||||||
ON CONFLICT (source_kind, source_prospective_item_id) DO UPDATE SET source_prospective_item_id=EXCLUDED.source_prospective_item_id
|
ON CONFLICT (source_kind, source_prospective_item_id) DO UPDATE SET
|
||||||
|
state=EXCLUDED.state,
|
||||||
|
due_at_unix_millis=EXCLUDED.due_at_unix_millis,
|
||||||
|
created_at_unix_millis=EXCLUDED.created_at_unix_millis,
|
||||||
|
delivered_at_unix_millis=0,
|
||||||
|
action_intent_id='',
|
||||||
|
authorized_action_id='',
|
||||||
|
action_attempt_id='',
|
||||||
|
delivery_outcome='',
|
||||||
|
response_id='',
|
||||||
|
response_utterance_id='',
|
||||||
|
response_operation='',
|
||||||
|
response_outcome='',
|
||||||
|
responded_at_unix_millis=0,
|
||||||
|
revision=continuity.attention_items.revision+1
|
||||||
RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,due_at_unix_millis,
|
RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,due_at_unix_millis,
|
||||||
created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,revision`,
|
created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||||
|
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision`,
|
||||||
attention.ID, attention.OwnerPrincipalID, attention.ScopeID, attention.SourceKind, attention.SourceProspectiveID,
|
attention.ID, attention.OwnerPrincipalID, attention.ScopeID, attention.SourceKind, attention.SourceProspectiveID,
|
||||||
attention.Level, attention.State, attention.Summary, attention.DueAtUnixMillis, attention.CreatedAtUnixMillis).Scan(
|
attention.Level, attention.State, attention.Summary, attention.DueAtUnixMillis, attention.CreatedAtUnixMillis).Scan(
|
||||||
&attention.ID, &attention.OwnerPrincipalID, &attention.ScopeID, &attention.SourceKind, &attention.SourceProspectiveID,
|
&attention.ID, &attention.OwnerPrincipalID, &attention.ScopeID, &attention.SourceKind, &attention.SourceProspectiveID,
|
||||||
&attention.Level, &attention.State, &attention.Summary, &attention.DueAtUnixMillis, &attention.CreatedAtUnixMillis,
|
&attention.Level, &attention.State, &attention.Summary, &attention.DueAtUnixMillis, &attention.CreatedAtUnixMillis,
|
||||||
&attention.DeliveredAtUnixMillis, &attention.ActionIntentID, &attention.AuthorizedActionID, &attention.ActionAttemptID,
|
&attention.DeliveredAtUnixMillis, &attention.ActionIntentID, &attention.AuthorizedActionID, &attention.ActionAttemptID,
|
||||||
&attention.DeliveryOutcome, &attention.Revision)
|
&attention.DeliveryOutcome, &attention.ResponseID, &attention.ResponseUtteranceID, &attention.ResponseOperation,
|
||||||
|
&attention.ResponseOutcome, &attention.RespondedAtUnixMillis, &attention.Revision)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return continuity.ProspectiveItem{}, continuity.AttentionItem{}, fmt.Errorf("create due reminder attention: %w", err)
|
return continuity.ProspectiveItem{}, continuity.AttentionItem{}, fmt.Errorf("create due reminder attention: %w", err)
|
||||||
}
|
}
|
||||||
@@ -170,7 +187,8 @@ func (r *ContinuityRepository) PendingAttention(ctx context.Context, limit int)
|
|||||||
return nil, fmt.Errorf("invalid attention limit")
|
return nil, fmt.Errorf("invalid attention limit")
|
||||||
}
|
}
|
||||||
rows, err := r.pool.Query(ctx, `SELECT attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,
|
rows, err := r.pool.Query(ctx, `SELECT attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,
|
||||||
due_at_unix_millis,created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,revision
|
due_at_unix_millis,created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||||
|
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision
|
||||||
FROM continuity.attention_items WHERE state=$1 ORDER BY due_at_unix_millis, attention_item_id LIMIT $2`, continuity.AttentionPending, limit)
|
FROM continuity.attention_items WHERE state=$1 ORDER BY due_at_unix_millis, attention_item_id LIMIT $2`, continuity.AttentionPending, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list pending attention: %w", err)
|
return nil, fmt.Errorf("list pending attention: %w", err)
|
||||||
@@ -184,7 +202,8 @@ func (r *ContinuityRepository) AttentionByOwner(ctx context.Context, owner ident
|
|||||||
return nil, fmt.Errorf("invalid attention owner query")
|
return nil, fmt.Errorf("invalid attention owner query")
|
||||||
}
|
}
|
||||||
rows, err := r.pool.Query(ctx, `SELECT attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,
|
rows, err := r.pool.Query(ctx, `SELECT attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,
|
||||||
due_at_unix_millis,created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,revision
|
due_at_unix_millis,created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||||
|
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision
|
||||||
FROM continuity.attention_items WHERE owner_principal_id=$1 ORDER BY due_at_unix_millis DESC, attention_item_id DESC LIMIT $2`, owner, limit)
|
FROM continuity.attention_items WHERE owner_principal_id=$1 ORDER BY due_at_unix_millis DESC, attention_item_id DESC LIMIT $2`, owner, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list attention by owner: %w", err)
|
return nil, fmt.Errorf("list attention by owner: %w", err)
|
||||||
@@ -193,16 +212,137 @@ func (r *ContinuityRepository) AttentionByOwner(ctx context.Context, owner ident
|
|||||||
return scanAttentionRows(rows)
|
return scanAttentionRows(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ContinuityRepository) ResponseCandidates(ctx context.Context, owner identity.PrincipalID, limit int) ([]continuitydomain.ResponseCandidate, error) {
|
||||||
|
if owner == "" || limit < 1 || limit > 10 {
|
||||||
|
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,
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
LIMIT $8`,
|
||||||
|
owner, continuity.AttentionSourceProspectiveReminder, continuity.AttentionDelivered, continuity.AttentionPending,
|
||||||
|
continuity.AttentionFailed, continuity.ProspectiveDelivered, continuity.ProspectiveArmed, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list human response candidates: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var candidates []continuitydomain.ResponseCandidate
|
||||||
|
for rows.Next() {
|
||||||
|
var candidate continuitydomain.ResponseCandidate
|
||||||
|
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,
|
||||||
|
&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)
|
||||||
|
}
|
||||||
|
candidates = append(candidates, candidate)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("list human response candidates: %w", err)
|
||||||
|
}
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ContinuityRepository) ApplyHumanResponse(ctx context.Context, response continuity.HumanResponse) error {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
if _, err = tx.Exec(ctx, `INSERT INTO continuity.human_responses (
|
||||||
|
human_response_id,owner_principal_id,scope_id,source_utterance_id,source_intent_id,operation,target_kind,
|
||||||
|
target_attention_id,target_prospective_item_id,target_external_ref,resolution_state,outcome,clarification,
|
||||||
|
snooze_until_unix_millis,user_reported_not_verified,recorded_at_unix_millis,revision)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`,
|
||||||
|
response.ID, response.OwnerPrincipalID, response.ScopeID, response.SourceUtteranceID, response.SourceIntentID,
|
||||||
|
response.Operation, response.TargetKind, response.TargetAttentionID, response.TargetProspectiveID, response.TargetExternalRef,
|
||||||
|
response.ResolutionState, response.Outcome, response.Clarification, response.SnoozeUntilUnixMillis,
|
||||||
|
response.UserReportedNotVerified, response.RecordedAtUnixMillis, response.Revision); err != nil {
|
||||||
|
return fmt.Errorf("save human response: %w", err)
|
||||||
|
}
|
||||||
|
if response.ResolutionState != continuity.ResponseResolved {
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
switch response.Operation {
|
||||||
|
case continuity.ResponseComplete:
|
||||||
|
tag, err := tx.Exec(ctx, `UPDATE continuity.prospective_items SET state=$1, revision=revision+1
|
||||||
|
WHERE prospective_item_id=$2 AND owner_principal_id=$3 AND state IN ($4,$5)`,
|
||||||
|
continuity.ProspectiveSatisfied, response.TargetProspectiveID, response.OwnerPrincipalID,
|
||||||
|
continuity.ProspectiveDelivered, continuity.ProspectiveArmed)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("complete response prospective: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() != 1 {
|
||||||
|
return ErrProspectiveStateConflict
|
||||||
|
}
|
||||||
|
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionResolved); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case continuity.ResponseSnooze:
|
||||||
|
tag, err := tx.Exec(ctx, `UPDATE continuity.prospective_items SET state=$1, trigger_at_unix_millis=$2, revision=revision+1
|
||||||
|
WHERE prospective_item_id=$3 AND owner_principal_id=$4 AND state IN ($5,$6)`,
|
||||||
|
continuity.ProspectiveArmed, response.SnoozeUntilUnixMillis, response.TargetProspectiveID,
|
||||||
|
response.OwnerPrincipalID, continuity.ProspectiveDelivered, continuity.ProspectiveArmed)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("snooze response prospective: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() != 1 {
|
||||||
|
return ErrProspectiveStateConflict
|
||||||
|
}
|
||||||
|
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
|
||||||
|
WHERE prospective_item_id=$2 AND owner_principal_id=$3 AND state IN ($4,$5)`,
|
||||||
|
continuity.ProspectiveSuppressed, response.TargetProspectiveID, response.OwnerPrincipalID,
|
||||||
|
continuity.ProspectiveDelivered, continuity.ProspectiveArmed)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cancel response prospective: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() != 1 {
|
||||||
|
return ErrProspectiveStateConflict
|
||||||
|
}
|
||||||
|
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionSuppressed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case continuity.ResponseAcknowledge, continuity.ResponseDismiss:
|
||||||
|
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionResolved); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ContinuityRepository) MarkAttentionDelivering(ctx context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision) (continuity.AttentionItem, error) {
|
func (r *ContinuityRepository) MarkAttentionDelivering(ctx context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision) (continuity.AttentionItem, error) {
|
||||||
var item continuity.AttentionItem
|
var item continuity.AttentionItem
|
||||||
err := r.pool.QueryRow(ctx, `UPDATE continuity.attention_items SET state=$1, revision=revision+1
|
err := r.pool.QueryRow(ctx, `UPDATE continuity.attention_items SET state=$1, revision=revision+1
|
||||||
WHERE attention_item_id=$2 AND state=$3 AND revision=$4
|
WHERE attention_item_id=$2 AND state=$3 AND revision=$4
|
||||||
RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,due_at_unix_millis,
|
RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,due_at_unix_millis,
|
||||||
created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,revision`,
|
created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||||
|
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision`,
|
||||||
continuity.AttentionDelivering, id, continuity.AttentionPending, revision).Scan(
|
continuity.AttentionDelivering, id, continuity.AttentionPending, revision).Scan(
|
||||||
&item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID, &item.Level, &item.State,
|
&item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID, &item.Level, &item.State,
|
||||||
&item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis, &item.DeliveredAtUnixMillis, &item.ActionIntentID,
|
&item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis, &item.DeliveredAtUnixMillis, &item.ActionIntentID,
|
||||||
&item.AuthorizedActionID, &item.ActionAttemptID, &item.DeliveryOutcome, &item.Revision)
|
&item.AuthorizedActionID, &item.ActionAttemptID, &item.DeliveryOutcome, &item.ResponseID, &item.ResponseUtteranceID,
|
||||||
|
&item.ResponseOperation, &item.ResponseOutcome, &item.RespondedAtUnixMillis, &item.Revision)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return continuity.AttentionItem{}, ErrAttentionRevisionConflict
|
return continuity.AttentionItem{}, ErrAttentionRevisionConflict
|
||||||
}
|
}
|
||||||
@@ -216,12 +356,14 @@ func (r *ContinuityRepository) MarkAttentionDelivered(ctx context.Context, id co
|
|||||||
action_attempt_id=$5, delivery_outcome=$6
|
action_attempt_id=$5, delivery_outcome=$6
|
||||||
WHERE attention_item_id=$7 AND state=$8 AND revision=$9
|
WHERE attention_item_id=$7 AND state=$8 AND revision=$9
|
||||||
RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,due_at_unix_millis,
|
RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,due_at_unix_millis,
|
||||||
created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,revision`,
|
created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||||
|
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision`,
|
||||||
continuity.AttentionDelivered, deliveredAtUnixMillis, intentID, authorizedID, attemptID, outcome,
|
continuity.AttentionDelivered, deliveredAtUnixMillis, intentID, authorizedID, attemptID, outcome,
|
||||||
id, continuity.AttentionDelivering, revision).Scan(
|
id, continuity.AttentionDelivering, revision).Scan(
|
||||||
&item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID, &item.Level, &item.State,
|
&item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID, &item.Level, &item.State,
|
||||||
&item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis, &item.DeliveredAtUnixMillis, &item.ActionIntentID,
|
&item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis, &item.DeliveredAtUnixMillis, &item.ActionIntentID,
|
||||||
&item.AuthorizedActionID, &item.ActionAttemptID, &item.DeliveryOutcome, &item.Revision)
|
&item.AuthorizedActionID, &item.ActionAttemptID, &item.DeliveryOutcome, &item.ResponseID, &item.ResponseUtteranceID,
|
||||||
|
&item.ResponseOperation, &item.ResponseOutcome, &item.RespondedAtUnixMillis, &item.Revision)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return continuity.AttentionItem{}, ErrAttentionRevisionConflict
|
return continuity.AttentionItem{}, ErrAttentionRevisionConflict
|
||||||
}
|
}
|
||||||
@@ -235,12 +377,14 @@ func (r *ContinuityRepository) MarkAttentionFailed(ctx context.Context, id conti
|
|||||||
action_attempt_id=$4, delivery_outcome=$5
|
action_attempt_id=$4, delivery_outcome=$5
|
||||||
WHERE attention_item_id=$6 AND state=$7 AND revision=$8
|
WHERE attention_item_id=$6 AND state=$7 AND revision=$8
|
||||||
RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,due_at_unix_millis,
|
RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,due_at_unix_millis,
|
||||||
created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,revision`,
|
created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||||
|
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision`,
|
||||||
continuity.AttentionFailed, intentID, authorizedID, attemptID, outcome,
|
continuity.AttentionFailed, intentID, authorizedID, attemptID, outcome,
|
||||||
id, continuity.AttentionDelivering, revision).Scan(
|
id, continuity.AttentionDelivering, revision).Scan(
|
||||||
&item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID, &item.Level, &item.State,
|
&item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID, &item.Level, &item.State,
|
||||||
&item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis, &item.DeliveredAtUnixMillis, &item.ActionIntentID,
|
&item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis, &item.DeliveredAtUnixMillis, &item.ActionIntentID,
|
||||||
&item.AuthorizedActionID, &item.ActionAttemptID, &item.DeliveryOutcome, &item.Revision)
|
&item.AuthorizedActionID, &item.ActionAttemptID, &item.DeliveryOutcome, &item.ResponseID, &item.ResponseUtteranceID,
|
||||||
|
&item.ResponseOperation, &item.ResponseOutcome, &item.RespondedAtUnixMillis, &item.Revision)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return continuity.AttentionItem{}, ErrAttentionRevisionConflict
|
return continuity.AttentionItem{}, ErrAttentionRevisionConflict
|
||||||
}
|
}
|
||||||
@@ -316,12 +460,32 @@ type continuityRowQuerier interface {
|
|||||||
QueryRow(context.Context, string, ...any) pgx.Row
|
QueryRow(context.Context, string, ...any) pgx.Row
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type continuityExecutor interface {
|
||||||
|
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
|
||||||
|
}
|
||||||
|
|
||||||
type continuityRows interface {
|
type continuityRows interface {
|
||||||
Next() bool
|
Next() bool
|
||||||
Scan(...any) error
|
Scan(...any) error
|
||||||
Err() error
|
Err() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func markAttentionResponded(ctx context.Context, executor continuityExecutor, response continuity.HumanResponse, state continuity.AttentionState) error {
|
||||||
|
tag, err := executor.Exec(ctx, `UPDATE continuity.attention_items
|
||||||
|
SET state=$1, response_id=$2, response_utterance_id=$3, response_operation=$4, response_outcome=$5,
|
||||||
|
responded_at_unix_millis=$6, revision=revision+1
|
||||||
|
WHERE attention_item_id=$7 AND owner_principal_id=$8 AND state IN ($9,$10,$11)`,
|
||||||
|
state, response.ID, response.SourceUtteranceID, response.Operation, response.Outcome, response.RecordedAtUnixMillis,
|
||||||
|
response.TargetAttentionID, response.OwnerPrincipalID, continuity.AttentionDelivered, continuity.AttentionPending, continuity.AttentionFailed)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mark attention responded: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() != 1 {
|
||||||
|
return ErrAttentionStateConflict
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func scanAttentionRows(rows continuityRows) ([]continuity.AttentionItem, error) {
|
func scanAttentionRows(rows continuityRows) ([]continuity.AttentionItem, error) {
|
||||||
items := make([]continuity.AttentionItem, 0)
|
items := make([]continuity.AttentionItem, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
@@ -329,7 +493,8 @@ func scanAttentionRows(rows continuityRows) ([]continuity.AttentionItem, error)
|
|||||||
if err := rows.Scan(&item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID,
|
if err := rows.Scan(&item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID,
|
||||||
&item.Level, &item.State, &item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis,
|
&item.Level, &item.State, &item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis,
|
||||||
&item.DeliveredAtUnixMillis, &item.ActionIntentID, &item.AuthorizedActionID, &item.ActionAttemptID,
|
&item.DeliveredAtUnixMillis, &item.ActionIntentID, &item.AuthorizedActionID, &item.ActionAttemptID,
|
||||||
&item.DeliveryOutcome, &item.Revision); err != nil {
|
&item.DeliveryOutcome, &item.ResponseID, &item.ResponseUtteranceID, &item.ResponseOperation,
|
||||||
|
&item.ResponseOutcome, &item.RespondedAtUnixMillis, &item.Revision); err != nil {
|
||||||
return nil, fmt.Errorf("scan attention item: %w", err)
|
return nil, fmt.Errorf("scan attention item: %w", err)
|
||||||
}
|
}
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# ADR-0015 — Human Response and Continuation Semantics V1
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Task: DEV-000066
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
DEV-000065 made Domovoy present beyond an HTTP request: reminders can become due, produce Continuity-owned Attention, and deliver a governed notification. The next step is not a reminder command set; it is a general way to interpret a human response to an existing context.
|
||||||
|
|
||||||
|
Examples include: a reminder after notification, waiting work requiring a decision, a future email thread, or a future capability gap/acquisition context. V1 implements reminders as the first real target while preserving extensible typed target references for future owners.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
1. Continuity owns `HumanResponse` and `ResponseTargetResolution` V1 because they continue durable waits/attention and may mutate Continuity-owned future obligation state.
|
||||||
|
2. Interaction remains the owner of the original `HumanUtterance` and `OriginalHumanIntent`; Continuity stores references to those records, not copies of their authority.
|
||||||
|
3. V1 response operations are typed but extensible: `ACKNOWLEDGE`, `COMPLETE`, `SNOOZE`, `CANCEL`, `DISMISS`, `CONTINUE`, `REJECT`, plus `UNKNOWN` for fail-closed interpretation.
|
||||||
|
4. V1 target references are typed: `ATTENTION_ITEM`, `PROSPECTIVE_ITEM`, `WORK_TASK`, `CAPABILITY_GAP`, `INTERACTION_CONTEXT`, and `UNKNOWN`. DEV-000066 implements only Continuity-owned reminder/attention mutations.
|
||||||
|
5. Obvious deterministic responses may execute without cognition. Ambiguous responses must ask for clarification and record an append-only response record without mutating the target.
|
||||||
|
6. A human response is not automatic verified world truth. For reminder completion, V1 records `USER_REPORTED_COMPLETE`; this satisfies the reminder according to policy but does not create a verified external-world fact.
|
||||||
|
7. UI buttons, if present, must post the same semantic response text/API path as natural chat. They are projections, not a separate command system.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- Continuity writes only Continuity-owned response, attention, and prospective state.
|
||||||
|
- Interaction writes conversations/utterances/intents before Continuity response handling.
|
||||||
|
- No external mutation or Commit/Executor operation is introduced in DEV-000066.
|
||||||
|
- No model output directly mutates canonical state.
|
||||||
|
- Future Codex/mail/capability-gap continuations are representable by typed references, but not implemented.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No Codex Bridge, Gmail, MCP, voice, mobile, browser automation, self-extension loop, or generalized orchestration.
|
||||||
|
- No universal object ontology.
|
||||||
|
- No reminder-specific parallel subsystem.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- “Готово”, “Отложи”, “Отмени” can continue the latest obvious reminder/attention context without internal IDs.
|
||||||
|
- Snooze re-arms the same `ProspectiveItem` with a new due time, preserving audit history.
|
||||||
|
- Cancel suppresses the future obligation so the due scanner will not deliver it.
|
||||||
|
- Today/Attention can show active, snoozed, completed, and cancelled human-readable states.
|
||||||
@@ -99,7 +99,8 @@
|
|||||||
"owns": [
|
"owns": [
|
||||||
"Commitment",
|
"Commitment",
|
||||||
"ProspectiveItem",
|
"ProspectiveItem",
|
||||||
"AttentionItem"
|
"AttentionItem",
|
||||||
|
"HumanResponse"
|
||||||
],
|
],
|
||||||
"database_schema": "continuity",
|
"database_schema": "continuity",
|
||||||
"boundary_class": "remote_capable",
|
"boundary_class": "remote_capable",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ modules:
|
|||||||
|
|
||||||
continuity:
|
continuity:
|
||||||
id: mod.continuity
|
id: mod.continuity
|
||||||
owns: [Commitment, ProspectiveItem, AttentionItem]
|
owns: [Commitment, ProspectiveItem, AttentionItem, HumanResponse]
|
||||||
database_schema: continuity
|
database_schema: continuity
|
||||||
boundary_class: remote_capable
|
boundary_class: remote_capable
|
||||||
may_depend_on: [contracts.work, contracts.identity, contracts.authority, platform.events]
|
may_depend_on: [contracts.work, contracts.identity, contracts.authority, platform.events]
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ func registerCreatorAttentionRoutes(mux *http.ServeMux, identityClient creatorId
|
|||||||
HumanState: humanAttentionState(item.State),
|
HumanState: humanAttentionState(item.State),
|
||||||
DeliveryState: humanDeliveryState(item),
|
DeliveryState: humanDeliveryState(item),
|
||||||
SourceDescription: humanAttentionSource(item.SourceKind),
|
SourceDescription: humanAttentionSource(item.SourceKind),
|
||||||
|
ResponseHint: humanResponseHint(item),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -83,6 +84,8 @@ func humanAttentionState(state continuity.AttentionState) string {
|
|||||||
return "доставляется"
|
return "доставляется"
|
||||||
case continuity.AttentionDelivered:
|
case continuity.AttentionDelivered:
|
||||||
return "доставлено"
|
return "доставлено"
|
||||||
|
case continuity.AttentionResolved:
|
||||||
|
return "закрыто"
|
||||||
case continuity.AttentionSuppressed:
|
case continuity.AttentionSuppressed:
|
||||||
return "скрыто"
|
return "скрыто"
|
||||||
case continuity.AttentionExpired:
|
case continuity.AttentionExpired:
|
||||||
@@ -94,6 +97,16 @@ func humanAttentionState(state continuity.AttentionState) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func humanResponseHint(item continuity.AttentionItem) string {
|
||||||
|
if item.State == continuity.AttentionDelivered || item.State == continuity.AttentionPending || item.State == continuity.AttentionFailed {
|
||||||
|
return "Можно ответить: «Готово», «Отложи на 10 минут» или «Отмени»."
|
||||||
|
}
|
||||||
|
if item.ResponseOutcome != "" {
|
||||||
|
return "Ответ записан: " + string(item.ResponseOutcome)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func humanDeliveryState(item continuity.AttentionItem) string {
|
func humanDeliveryState(item continuity.AttentionItem) string {
|
||||||
if item.State == continuity.AttentionDelivered {
|
if item.State == continuity.AttentionDelivered {
|
||||||
return "уведомление отправлено"
|
return "уведомление отправлено"
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
"git.cin.su/m/Assistant/platform/ids"
|
||||||
|
)
|
||||||
|
|
||||||
|
type responseRepository interface {
|
||||||
|
ResponseCandidates(context.Context, identity.PrincipalID, int) ([]continuitydomain.ResponseCandidate, error)
|
||||||
|
ApplyHumanResponse(context.Context, continuity.HumanResponse) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerCreatorResponseRoute(mux *http.ServeMux, identityClient creatorIdentityClient, repository responseRepository, now func() time.Time) {
|
||||||
|
mux.HandleFunc("POST /v1/creator/responses", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, err := identityClient.Session(r.Context(), r.Header.Get("Authorization"))
|
||||||
|
if errors.Is(err, errCreatorSessionDenied) {
|
||||||
|
http.Error(w, "creator session denied", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "identity service unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request continuity.HumanResponseRequest
|
||||||
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&request); err != nil {
|
||||||
|
http.Error(w, "invalid human response", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
responseID, err := ids.New()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "human response unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
candidates, err := repository.ResponseCandidates(r.Context(), session.PrincipalID, 3)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "response candidates unavailable", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := continuitydomain.InterpretHumanResponse(request, session.PrincipalID, authority.ScopeID("domovoy-global-scope"), continuity.HumanResponseID(responseID), candidates, now())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid human response", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result.Handled {
|
||||||
|
if err := repository.ApplyHumanResponse(r.Context(), result.Response); err != nil {
|
||||||
|
http.Error(w, "apply human response", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(result)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/continuity"
|
||||||
|
"git.cin.su/m/Assistant/contracts/identity"
|
||||||
|
continuitydomain "git.cin.su/m/Assistant/modules/continuity/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreatorResponseEndpointAppliesCompleteThroughContinuitySemantics(t *testing.T) {
|
||||||
|
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/creator/sessions/current" || r.Header.Get("Authorization") != "Bearer ok" {
|
||||||
|
t.Fatal("unexpected identity validation")
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor-1", PrincipalID: "principal-1"})
|
||||||
|
}))
|
||||||
|
defer core.Close()
|
||||||
|
identityClient, err := newCreatorIdentityClient(core.URL, core.Client())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repository := &fakeResponseRepository{candidates: []continuitydomain.ResponseCandidate{{
|
||||||
|
Attention: continuity.AttentionItem{ID: "attention-1", OwnerPrincipalID: "principal-1", ScopeID: "scope-1", SourceKind: continuity.AttentionSourceProspectiveReminder, SourceProspectiveID: "prospective-1", State: continuity.AttentionDelivered, Summary: "проверить базу", Revision: 1},
|
||||||
|
Prospective: continuity.ProspectiveItem{ID: "prospective-1", OwnerPrincipalID: "principal-1", ScopeID: "scope-1", State: continuity.ProspectiveDelivered, Summary: "проверить базу", Revision: 1},
|
||||||
|
}}}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
registerCreatorResponseRoute(mux, identityClient, repository, func() time.Time { return time.Unix(100, 0) })
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/v1/creator/responses", strings.NewReader(`{"SourceUtteranceID":"utterance-1","SourceIntentID":"intent-1","Message":"Готово","RequestedAtUnixMillis":100000}`))
|
||||||
|
request.Header.Set("Authorization", "Bearer ok")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusCreated || repository.applied.Operation != continuity.ResponseComplete || repository.applied.Outcome != continuity.ResponseOutcomeUserReportedComplete {
|
||||||
|
t.Fatalf("status=%d applied=%#v body=%s", recorder.Code, repository.applied, recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatorResponseEndpointRecordsAmbiguousWithoutSilentTargetMutation(t *testing.T) {
|
||||||
|
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor-1", PrincipalID: "principal-1"})
|
||||||
|
}))
|
||||||
|
defer core.Close()
|
||||||
|
identityClient, err := newCreatorIdentityClient(core.URL, core.Client())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repository := &fakeResponseRepository{candidates: []continuitydomain.ResponseCandidate{
|
||||||
|
{Attention: continuity.AttentionItem{ID: "attention-1", SourceProspectiveID: "prospective-1", State: continuity.AttentionDelivered, Summary: "первое"}},
|
||||||
|
{Attention: continuity.AttentionItem{ID: "attention-2", SourceProspectiveID: "prospective-2", State: continuity.AttentionDelivered, Summary: "второе"}},
|
||||||
|
}}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
registerCreatorResponseRoute(mux, identityClient, repository, func() time.Time { return time.Unix(100, 0) })
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/v1/creator/responses", strings.NewReader(`{"SourceUtteranceID":"utterance-1","SourceIntentID":"intent-1","Message":"Готово","RequestedAtUnixMillis":100000}`))
|
||||||
|
request.Header.Set("Authorization", "Bearer ok")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusCreated || repository.applied.ResolutionState != continuity.ResponseAmbiguous || repository.applied.TargetAttentionID != "" {
|
||||||
|
t.Fatalf("status=%d applied=%#v body=%s", recorder.Code, repository.applied, recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeResponseRepository struct {
|
||||||
|
candidates []continuitydomain.ResponseCandidate
|
||||||
|
applied continuity.HumanResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeResponseRepository) ResponseCandidates(context.Context, identity.PrincipalID, int) ([]continuitydomain.ResponseCandidate, error) {
|
||||||
|
return f.candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeResponseRepository) ApplyHumanResponse(_ context.Context, response continuity.HumanResponse) error {
|
||||||
|
f.applied = response
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ func main() {
|
|||||||
defer cancelWorker()
|
defer cancelWorker()
|
||||||
dueReminderWorker{repository: repository, commit: commitClient, now: time.Now, newID: ids.New, interval: 5 * time.Second, limit: 10}.Start(workerContext)
|
dueReminderWorker{repository: repository, commit: commitClient, now: time.Now, newID: ids.New, interval: 5 * time.Second, limit: 10}.Start(workerContext)
|
||||||
registerInternalExecutorRoutes(mux, repository, executorToken)
|
registerInternalExecutorRoutes(mux, repository, executorToken)
|
||||||
|
registerCreatorResponseRoute(mux, identityClient, repository, time.Now)
|
||||||
mux.HandleFunc("POST /v1/prospective-items", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("POST /v1/prospective-items", func(w http.ResponseWriter, r *http.Request) {
|
||||||
var item continuity.ProspectiveItem
|
var item continuity.ProspectiveItem
|
||||||
if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ type continuityReminderClient struct {
|
|||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type continuityResponseClient struct {
|
||||||
|
baseURL *url.URL
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
func newContinuityReminderClient(rawURL string, client *http.Client) (continuityReminderClient, error) {
|
func newContinuityReminderClient(rawURL string, client *http.Client) (continuityReminderClient, error) {
|
||||||
base, err := url.Parse(rawURL)
|
base, err := url.Parse(rawURL)
|
||||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||||
@@ -39,6 +44,17 @@ func newContinuityReminderClient(rawURL string, client *http.Client) (continuity
|
|||||||
return continuityReminderClient{baseURL: base, client: client}, nil
|
return continuityReminderClient{baseURL: base, client: client}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newContinuityResponseClient(rawURL string, client *http.Client) (continuityResponseClient, error) {
|
||||||
|
base, err := url.Parse(rawURL)
|
||||||
|
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||||
|
return continuityResponseClient{}, fmt.Errorf("invalid Continuity Runtime URL")
|
||||||
|
}
|
||||||
|
if client == nil {
|
||||||
|
client = http.DefaultClient
|
||||||
|
}
|
||||||
|
return continuityResponseClient{baseURL: base, client: client}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c continuityReminderClient) Schedule(ctx context.Context, authorization string, request continuity.ScheduleReminderRequest) (continuity.ScheduleReminderResult, error) {
|
func (c continuityReminderClient) Schedule(ctx context.Context, authorization string, request continuity.ScheduleReminderRequest) (continuity.ScheduleReminderResult, error) {
|
||||||
target := *c.baseURL
|
target := *c.baseURL
|
||||||
target.Path = "/v1/creator/reminders"
|
target.Path = "/v1/creator/reminders"
|
||||||
@@ -70,6 +86,34 @@ func (c continuityReminderClient) Schedule(ctx context.Context, authorization st
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c continuityResponseClient) Respond(ctx context.Context, authorization string, request continuity.HumanResponseRequest) (continuity.HumanResponseResult, error) {
|
||||||
|
target := *c.baseURL
|
||||||
|
target.Path = "/v1/creator/responses"
|
||||||
|
payload, err := json.Marshal(request)
|
||||||
|
if err != nil {
|
||||||
|
return continuity.HumanResponseResult{}, err
|
||||||
|
}
|
||||||
|
upstream, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return continuity.HumanResponseResult{}, err
|
||||||
|
}
|
||||||
|
upstream.Header.Set("Content-Type", "application/json")
|
||||||
|
upstream.Header.Set("Authorization", authorization)
|
||||||
|
response, err := c.client.Do(upstream)
|
||||||
|
if err != nil {
|
||||||
|
return continuity.HumanResponseResult{}, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusCreated {
|
||||||
|
return continuity.HumanResponseResult{}, fmt.Errorf("continuity response status %d", response.StatusCode)
|
||||||
|
}
|
||||||
|
var result continuity.HumanResponseResult
|
||||||
|
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||||
|
return continuity.HumanResponseResult{}, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func resolveChatCapability(ctx context.Context, repository *postgres.CapabilityRepository, kind capability.CapabilityKind, session identity.CreatorSession, intent interaction.OriginalHumanIntent, utterance interaction.HumanUtterance, now time.Time) (capability.CapabilityResolution, error) {
|
func resolveChatCapability(ctx context.Context, repository *postgres.CapabilityRepository, kind capability.CapabilityKind, session identity.CreatorSession, intent interaction.OriginalHumanIntent, utterance interaction.HumanUtterance, now time.Time) (capability.CapabilityResolution, error) {
|
||||||
requirementID, err := ids.New()
|
requirementID, err := ids.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -90,6 +90,10 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
responseClient, err := newContinuityResponseClient(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), &http.Client{Timeout: 5 * time.Second})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
authenticateCreator := func(w http.ResponseWriter, r *http.Request) (identity.CreatorSession, bool) {
|
authenticateCreator := func(w http.ResponseWriter, r *http.Request) (identity.CreatorSession, bool) {
|
||||||
session, err := identitydomain.ValidateCreatorSession(sessionKey, bearerToken(r), time.Now())
|
session, err := identitydomain.ValidateCreatorSession(sessionKey, bearerToken(r), time.Now())
|
||||||
@@ -235,6 +239,12 @@ func main() {
|
|||||||
http.Error(w, "save chat semantics", http.StatusInternalServerError)
|
http.Error(w, "save chat semantics", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
responseResult, responseErr := responseClient.Respond(r.Context(), r.Header.Get("Authorization"), continuity.HumanResponseRequest{
|
||||||
|
SourceUtteranceID: string(utterance.ID),
|
||||||
|
SourceIntentID: string(intent.ID),
|
||||||
|
Message: utterance.Content,
|
||||||
|
RequestedAtUnixMillis: now.UnixMilli(),
|
||||||
|
})
|
||||||
activeFocusName := ""
|
activeFocusName := ""
|
||||||
activeFocusProvenance := situation.ProjectResolutionProvenance("")
|
activeFocusProvenance := situation.ProjectResolutionProvenance("")
|
||||||
if semantic.ActiveFocus != nil {
|
if semantic.ActiveFocus != nil {
|
||||||
@@ -244,7 +254,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
activeFocusName = semantic.ActiveFocus.ProjectDisplayName
|
activeFocusName = semantic.ActiveFocus.ProjectDisplayName
|
||||||
activeFocusProvenance = semantic.ActiveFocus.Provenance
|
activeFocusProvenance = semantic.ActiveFocus.Provenance
|
||||||
} else if previousFocus != nil {
|
} else if len(semantic.Projects) > 0 && semantic.Projects[0].Provenance == situation.ProjectResolutionInheritedFocus && previousFocus != nil {
|
||||||
activeFocusName = previousFocus.ProjectDisplayName
|
activeFocusName = previousFocus.ProjectDisplayName
|
||||||
activeFocusProvenance = previousFocus.Provenance
|
activeFocusProvenance = previousFocus.Provenance
|
||||||
}
|
}
|
||||||
@@ -254,9 +264,13 @@ func main() {
|
|||||||
projectID := ""
|
projectID := ""
|
||||||
if semantic.ActiveFocus != nil {
|
if semantic.ActiveFocus != nil {
|
||||||
projectID = string(semantic.ActiveFocus.ProjectID)
|
projectID = string(semantic.ActiveFocus.ProjectID)
|
||||||
} else if previousFocus != nil {
|
} else if len(semantic.Projects) > 0 && semantic.Projects[0].Provenance == situation.ProjectResolutionInheritedFocus {
|
||||||
projectID = string(previousFocus.ProjectID)
|
projectID = string(previousFocus.ProjectID)
|
||||||
}
|
}
|
||||||
|
if responseErr == nil && responseResult.Handled {
|
||||||
|
planningReply = responseResult.Reply
|
||||||
|
needsCognition = false
|
||||||
|
}
|
||||||
planningInput := work.PlanningInput{
|
planningInput := work.PlanningInput{
|
||||||
ConversationID: work.ConversationRef(conversation.ID),
|
ConversationID: work.ConversationRef(conversation.ID),
|
||||||
UtteranceID: work.SourceUtteranceRef(utterance.ID),
|
UtteranceID: work.SourceUtteranceRef(utterance.ID),
|
||||||
@@ -273,6 +287,9 @@ func main() {
|
|||||||
ReceivedAtUnixMillis: utterance.ReceivedAtUnixMillis,
|
ReceivedAtUnixMillis: utterance.ReceivedAtUnixMillis,
|
||||||
}
|
}
|
||||||
helpPath := workdomain.DetermineHelpPath(planningInput)
|
helpPath := workdomain.DetermineHelpPath(planningInput)
|
||||||
|
if responseErr == nil && responseResult.Handled {
|
||||||
|
helpPath = work.HelpPath{}
|
||||||
|
}
|
||||||
if isCapabilityQuestion(utterance.Content) {
|
if isCapabilityQuestion(utterance.Content) {
|
||||||
kinds, err := capabilityRepository.AvailableCapabilityKinds(r.Context(), session.PrincipalID)
|
kinds, err := capabilityRepository.AvailableCapabilityKinds(r.Context(), session.PrincipalID)
|
||||||
if err == nil && len(kinds) > 0 {
|
if err == nil && len(kinds) > 0 {
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px;
|
|||||||
.attention-card time { color: var(--signal); font: 12px/1 var(--mono); }
|
.attention-card time { color: var(--signal); font: 12px/1 var(--mono); }
|
||||||
.attention-card h2 { margin: 10px 0 8px; font: 400 24px/1.1 var(--serif); }
|
.attention-card h2 { margin: 10px 0 8px; font: 400 24px/1.1 var(--serif); }
|
||||||
.attention-card p, .history-item p { margin: 0 0 10px; color: var(--ink); line-height: 1.45; }
|
.attention-card p, .history-item p { margin: 0 0 10px; color: var(--ink); line-height: 1.45; }
|
||||||
|
.attention-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
|
||||||
|
.attention-actions button { height: 34px; padding: 0 12px; border: 1px solid var(--line); background: #151e1a; color: var(--ink); font: 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
|
||||||
|
.attention-actions button:first-child { border-color: var(--good); color: var(--good); }
|
||||||
.history-item.user { border-color: #34443c; background: #151e1a; }
|
.history-item.user { border-color: #34443c; background: #151e1a; }
|
||||||
.history-item strong { display: block; margin-bottom: 8px; color: var(--signal); }
|
.history-item strong { display: block; margin-bottom: 8px; color: var(--signal); }
|
||||||
.empty-state { margin: 24px 0 0; color: var(--muted); }
|
.empty-state { margin: 24px 0 0; color: var(--muted); }
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ function appendMessage(kind, text, meta = "") {
|
|||||||
|
|
||||||
function updateActiveFocus(value, provenance = "") {
|
function updateActiveFocus(value, provenance = "") {
|
||||||
if (!activeFocus) return;
|
if (!activeFocus) return;
|
||||||
activeFocus.textContent = value ? `фокус: ${value}${provenance ? ` · ${provenance}` : ""}` : "фокус: не выбран";
|
activeFocus.textContent = value ? `Сейчас обсуждаем: ${value}` : "";
|
||||||
|
activeFocus.hidden = !value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
@@ -121,7 +122,27 @@ async function loadToday() {
|
|||||||
state.textContent = item.HumanState || item.human_state || "ожидает внимания";
|
state.textContent = item.HumanState || item.human_state || "ожидает внимания";
|
||||||
const source = document.createElement("small");
|
const source = document.createElement("small");
|
||||||
source.textContent = [item.SourceDescription || item.source_description, item.DeliveryState || item.delivery_state].filter(Boolean).join(" · ");
|
source.textContent = [item.SourceDescription || item.source_description, item.DeliveryState || item.delivery_state].filter(Boolean).join(" · ");
|
||||||
card.append(when, title, state, source);
|
const hintText = item.ResponseHint || item.response_hint || "";
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "attention-actions";
|
||||||
|
for (const [label, text] of [["Готово", "Готово"], ["Отложить", "Отложи на 10 минут"], ["Отменить", "Отмени"]]) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.textContent = label;
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
await sendChatMessage(text, {silentUser: false});
|
||||||
|
await loadToday();
|
||||||
|
activateTab("chat-panel");
|
||||||
|
});
|
||||||
|
actions.appendChild(button);
|
||||||
|
}
|
||||||
|
if (hintText) {
|
||||||
|
const hint = document.createElement("small");
|
||||||
|
hint.textContent = hintText;
|
||||||
|
card.append(when, title, state, source, hint, actions);
|
||||||
|
} else {
|
||||||
|
card.append(when, title, state, source, actions);
|
||||||
|
}
|
||||||
todayList.appendChild(card);
|
todayList.appendChild(card);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -133,6 +154,29 @@ async function loadToday() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendChatMessage(text, options = {}) {
|
||||||
|
const clean = text.trim();
|
||||||
|
if (!clean) return;
|
||||||
|
if (!options.silentUser) appendMessage("user", clean);
|
||||||
|
sendButton.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: clean})});
|
||||||
|
conversationID = result.conversation_id;
|
||||||
|
sessionStorage.setItem("domovoy.chat.conversation", conversationID);
|
||||||
|
updateActiveFocus(result.active_focus, result.project_provenance);
|
||||||
|
appendMessage("assistant", result.reply_text || "Сообщение записано.", humanMeta(result));
|
||||||
|
if (/что сейчас требует моего внимания/i.test(clean)) {
|
||||||
|
await loadToday();
|
||||||
|
activateTab("today-panel");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
appendMessage("assistant", "Чат сейчас недоступен. Сообщение не принято runtime.");
|
||||||
|
} finally {
|
||||||
|
sendButton.disabled = false;
|
||||||
|
messageInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function activateTab(panelID) {
|
function activateTab(panelID) {
|
||||||
for (const tab of navTabs) tab.classList.toggle("active", tab.dataset.tab === panelID);
|
for (const tab of navTabs) tab.classList.toggle("active", tab.dataset.tab === panelID);
|
||||||
for (const panel of document.querySelectorAll(".surface-panel")) {
|
for (const panel of document.querySelectorAll(".surface-panel")) {
|
||||||
@@ -163,25 +207,8 @@ chatForm.addEventListener("submit", async (event) => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const text = messageInput.value.trim();
|
const text = messageInput.value.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
appendMessage("user", text);
|
|
||||||
messageInput.value = "";
|
messageInput.value = "";
|
||||||
sendButton.disabled = true;
|
await sendChatMessage(text);
|
||||||
try {
|
|
||||||
const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: text})});
|
|
||||||
conversationID = result.conversation_id;
|
|
||||||
sessionStorage.setItem("domovoy.chat.conversation", conversationID);
|
|
||||||
updateActiveFocus(result.active_focus, result.project_provenance);
|
|
||||||
appendMessage("assistant", result.reply_text || "Сообщение записано.", humanMeta(result));
|
|
||||||
if (/что сейчас требует моего внимания/i.test(text)) {
|
|
||||||
await loadToday();
|
|
||||||
activateTab("today-panel");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
appendMessage("assistant", "Чат сейчас недоступен. Сообщение не принято runtime.");
|
|
||||||
} finally {
|
|
||||||
sendButton.disabled = false;
|
|
||||||
messageInput.focus();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const tab of navTabs) {
|
for (const tab of navTabs) {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ type AttentionRevision uint64
|
|||||||
type CommittedActionIntentID ids.ID
|
type CommittedActionIntentID ids.ID
|
||||||
type CommittedAuthorizedActionID ids.ID
|
type CommittedAuthorizedActionID ids.ID
|
||||||
type CommittedActionAttemptID ids.ID
|
type CommittedActionAttemptID ids.ID
|
||||||
|
type HumanResponseID ids.ID
|
||||||
|
type HumanResponseRevision uint64
|
||||||
|
|
||||||
type ProspectiveState string
|
type ProspectiveState string
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ const (
|
|||||||
AttentionPending AttentionState = "PENDING"
|
AttentionPending AttentionState = "PENDING"
|
||||||
AttentionDelivering AttentionState = "DELIVERING"
|
AttentionDelivering AttentionState = "DELIVERING"
|
||||||
AttentionDelivered AttentionState = "DELIVERED"
|
AttentionDelivered AttentionState = "DELIVERED"
|
||||||
|
AttentionResolved AttentionState = "RESOLVED"
|
||||||
AttentionSuppressed AttentionState = "SUPPRESSED"
|
AttentionSuppressed AttentionState = "SUPPRESSED"
|
||||||
AttentionExpired AttentionState = "EXPIRED"
|
AttentionExpired AttentionState = "EXPIRED"
|
||||||
AttentionFailed AttentionState = "FAILED"
|
AttentionFailed AttentionState = "FAILED"
|
||||||
@@ -106,6 +109,11 @@ type AttentionItem struct {
|
|||||||
AuthorizedActionID CommittedAuthorizedActionID
|
AuthorizedActionID CommittedAuthorizedActionID
|
||||||
ActionAttemptID CommittedActionAttemptID
|
ActionAttemptID CommittedActionAttemptID
|
||||||
DeliveryOutcome string
|
DeliveryOutcome string
|
||||||
|
ResponseID HumanResponseID
|
||||||
|
ResponseUtteranceID string
|
||||||
|
ResponseOperation HumanResponseOperation
|
||||||
|
ResponseOutcome ResponseOutcome
|
||||||
|
RespondedAtUnixMillis int64
|
||||||
Revision AttentionRevision
|
Revision AttentionRevision
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,12 +129,90 @@ type TodayItem struct {
|
|||||||
HumanState string
|
HumanState string
|
||||||
DeliveryState string
|
DeliveryState string
|
||||||
SourceDescription string
|
SourceDescription string
|
||||||
|
ResponseHint string
|
||||||
}
|
}
|
||||||
|
|
||||||
type TodayResult struct {
|
type TodayResult struct {
|
||||||
Items []TodayItem
|
Items []TodayItem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HumanResponseOperation string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ResponseAcknowledge HumanResponseOperation = "ACKNOWLEDGE"
|
||||||
|
ResponseComplete HumanResponseOperation = "COMPLETE"
|
||||||
|
ResponseSnooze HumanResponseOperation = "SNOOZE"
|
||||||
|
ResponseCancel HumanResponseOperation = "CANCEL"
|
||||||
|
ResponseDismiss HumanResponseOperation = "DISMISS"
|
||||||
|
ResponseContinue HumanResponseOperation = "CONTINUE"
|
||||||
|
ResponseReject HumanResponseOperation = "REJECT"
|
||||||
|
ResponseUnknown HumanResponseOperation = "UNKNOWN"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ResponseTargetKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ResponseTargetAttentionItem ResponseTargetKind = "ATTENTION_ITEM"
|
||||||
|
ResponseTargetProspectiveItem ResponseTargetKind = "PROSPECTIVE_ITEM"
|
||||||
|
ResponseTargetWorkTask ResponseTargetKind = "WORK_TASK"
|
||||||
|
ResponseTargetCapabilityGap ResponseTargetKind = "CAPABILITY_GAP"
|
||||||
|
ResponseTargetInteractionContext ResponseTargetKind = "INTERACTION_CONTEXT"
|
||||||
|
ResponseTargetUnknown ResponseTargetKind = "UNKNOWN"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ResponseResolutionState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ResponseResolved ResponseResolutionState = "RESOLVED"
|
||||||
|
ResponseAmbiguous ResponseResolutionState = "AMBIGUOUS"
|
||||||
|
ResponseNoTarget ResponseResolutionState = "NO_TARGET"
|
||||||
|
ResponseNoop ResponseResolutionState = "NOOP"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ResponseOutcome string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ResponseOutcomeUserReportedComplete ResponseOutcome = "USER_REPORTED_COMPLETE"
|
||||||
|
ResponseOutcomeSnoozed ResponseOutcome = "SNOOZED"
|
||||||
|
ResponseOutcomeCancelled ResponseOutcome = "CANCELLED"
|
||||||
|
ResponseOutcomeDismissed ResponseOutcome = "DISMISSED"
|
||||||
|
ResponseOutcomeClarificationNeeded ResponseOutcome = "CLARIFICATION_NEEDED"
|
||||||
|
ResponseOutcomeNoop ResponseOutcome = "NOOP"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HumanResponse struct {
|
||||||
|
ID HumanResponseID
|
||||||
|
OwnerPrincipalID identity.PrincipalID
|
||||||
|
ScopeID authority.ScopeID
|
||||||
|
SourceUtteranceID string
|
||||||
|
SourceIntentID string
|
||||||
|
Operation HumanResponseOperation
|
||||||
|
TargetKind ResponseTargetKind
|
||||||
|
TargetAttentionID AttentionItemID
|
||||||
|
TargetProspectiveID ProspectiveItemID
|
||||||
|
TargetExternalRef string
|
||||||
|
ResolutionState ResponseResolutionState
|
||||||
|
Outcome ResponseOutcome
|
||||||
|
Clarification string
|
||||||
|
SnoozeUntilUnixMillis int64
|
||||||
|
UserReportedNotVerified bool
|
||||||
|
RecordedAtUnixMillis int64
|
||||||
|
Revision HumanResponseRevision
|
||||||
|
}
|
||||||
|
|
||||||
|
type HumanResponseRequest struct {
|
||||||
|
SourceUtteranceID string
|
||||||
|
SourceIntentID string
|
||||||
|
Message string
|
||||||
|
RequestedAtUnixMillis int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type HumanResponseResult struct {
|
||||||
|
Response HumanResponse
|
||||||
|
Handled bool
|
||||||
|
Reply string
|
||||||
|
}
|
||||||
|
|
||||||
// SatisfyDeliveredSubject is the complete serializable operation subject. It
|
// SatisfyDeliveredSubject is the complete serializable operation subject. It
|
||||||
// is distinct from a Work TaskTarget, which remains the live justification.
|
// is distinct from a Work TaskTarget, which remains the live justification.
|
||||||
type SatisfyDeliveredSubject struct {
|
type SatisfyDeliveredSubject struct {
|
||||||
|
|||||||
@@ -594,17 +594,60 @@ CREATE TABLE IF NOT EXISTS continuity.attention_items (
|
|||||||
authorized_action_id TEXT NOT NULL DEFAULT '',
|
authorized_action_id TEXT NOT NULL DEFAULT '',
|
||||||
action_attempt_id TEXT NOT NULL DEFAULT '',
|
action_attempt_id TEXT NOT NULL DEFAULT '',
|
||||||
delivery_outcome TEXT NOT NULL DEFAULT '',
|
delivery_outcome TEXT NOT NULL DEFAULT '',
|
||||||
|
response_id TEXT NOT NULL DEFAULT '',
|
||||||
|
response_utterance_id TEXT NOT NULL DEFAULT '',
|
||||||
|
response_operation TEXT NOT NULL DEFAULT '',
|
||||||
|
response_outcome TEXT NOT NULL DEFAULT '',
|
||||||
|
responded_at_unix_millis BIGINT NOT NULL DEFAULT 0,
|
||||||
revision BIGINT NOT NULL,
|
revision BIGINT NOT NULL,
|
||||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE (source_kind, source_prospective_item_id),
|
UNIQUE (source_kind, source_prospective_item_id),
|
||||||
CHECK (source_kind IN ('PROSPECTIVE_REMINDER')),
|
CHECK (source_kind IN ('PROSPECTIVE_REMINDER')),
|
||||||
CHECK (level IN ('URGENT','ACTION_REQUIRED','DUE','IMPORTANT_CHANGE','FYI','BACKGROUND')),
|
CHECK (level IN ('URGENT','ACTION_REQUIRED','DUE','IMPORTANT_CHANGE','FYI','BACKGROUND')),
|
||||||
CHECK (state IN ('PENDING','DELIVERING','DELIVERED','SUPPRESSED','EXPIRED','FAILED')),
|
CHECK (state IN ('PENDING','DELIVERING','DELIVERED','RESOLVED','SUPPRESSED','EXPIRED','FAILED')),
|
||||||
CHECK (summary <> ''),
|
CHECK (summary <> ''),
|
||||||
CHECK (due_at_unix_millis > 0),
|
CHECK (due_at_unix_millis > 0),
|
||||||
CHECK (created_at_unix_millis > 0),
|
CHECK (created_at_unix_millis > 0),
|
||||||
CHECK (revision > 0),
|
CHECK (revision > 0),
|
||||||
CHECK (state <> 'DELIVERED' OR (delivered_at_unix_millis > 0 AND action_intent_id <> '' AND authorized_action_id <> '' AND action_attempt_id <> '' AND delivery_outcome <> ''))
|
CHECK (state <> 'DELIVERED' OR (delivered_at_unix_millis > 0 AND action_intent_id <> '' AND authorized_action_id <> '' AND action_attempt_id <> '' AND delivery_outcome <> '')),
|
||||||
|
CHECK (state <> 'RESOLVED' OR (
|
||||||
|
response_id <> '' AND response_utterance_id <> '' AND
|
||||||
|
response_operation IN ('ACKNOWLEDGE','COMPLETE','SNOOZE','CANCEL','DISMISS','CONTINUE','REJECT') AND
|
||||||
|
response_outcome IN ('USER_REPORTED_COMPLETE','SNOOZED','CANCELLED','DISMISSED','NOOP') AND
|
||||||
|
responded_at_unix_millis > 0
|
||||||
|
))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS continuity.human_responses (
|
||||||
|
human_response_id TEXT PRIMARY KEY,
|
||||||
|
owner_principal_id TEXT NOT NULL,
|
||||||
|
scope_id TEXT NOT NULL,
|
||||||
|
source_utterance_id TEXT NOT NULL,
|
||||||
|
source_intent_id TEXT NOT NULL,
|
||||||
|
operation TEXT NOT NULL,
|
||||||
|
target_kind TEXT NOT NULL,
|
||||||
|
target_attention_id TEXT NOT NULL DEFAULT '',
|
||||||
|
target_prospective_item_id TEXT NOT NULL DEFAULT '',
|
||||||
|
target_external_ref TEXT NOT NULL DEFAULT '',
|
||||||
|
resolution_state TEXT NOT NULL,
|
||||||
|
outcome TEXT NOT NULL,
|
||||||
|
clarification TEXT NOT NULL DEFAULT '',
|
||||||
|
snooze_until_unix_millis BIGINT NOT NULL DEFAULT 0,
|
||||||
|
user_reported_not_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
recorded_at_unix_millis BIGINT NOT NULL,
|
||||||
|
revision BIGINT NOT NULL,
|
||||||
|
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CHECK (owner_principal_id <> ''),
|
||||||
|
CHECK (scope_id <> ''),
|
||||||
|
CHECK (source_utterance_id <> ''),
|
||||||
|
CHECK (source_intent_id <> ''),
|
||||||
|
CHECK (operation IN ('ACKNOWLEDGE','COMPLETE','SNOOZE','CANCEL','DISMISS','CONTINUE','REJECT','UNKNOWN')),
|
||||||
|
CHECK (target_kind IN ('ATTENTION_ITEM','PROSPECTIVE_ITEM','WORK_TASK','CAPABILITY_GAP','INTERACTION_CONTEXT','UNKNOWN')),
|
||||||
|
CHECK (resolution_state IN ('RESOLVED','AMBIGUOUS','NO_TARGET','NOOP')),
|
||||||
|
CHECK (outcome IN ('USER_REPORTED_COMPLETE','SNOOZED','CANCELLED','DISMISSED','CLARIFICATION_NEEDED','NOOP')),
|
||||||
|
CHECK (recorded_at_unix_millis > 0),
|
||||||
|
CHECK (revision > 0),
|
||||||
|
CHECK (operation <> 'SNOOZE' OR snooze_until_unix_millis > recorded_at_unix_millis),
|
||||||
|
CHECK (resolution_state <> 'RESOLVED' OR target_kind <> 'UNKNOWN')
|
||||||
);
|
);
|
||||||
RESET ROLE;
|
RESET ROLE;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SET ROLE continuity_role;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS continuity.human_responses (
|
||||||
|
human_response_id TEXT PRIMARY KEY,
|
||||||
|
owner_principal_id TEXT NOT NULL,
|
||||||
|
scope_id TEXT NOT NULL,
|
||||||
|
source_utterance_id TEXT NOT NULL,
|
||||||
|
source_intent_id TEXT NOT NULL,
|
||||||
|
operation TEXT NOT NULL,
|
||||||
|
target_kind TEXT NOT NULL,
|
||||||
|
target_attention_id TEXT NOT NULL DEFAULT '',
|
||||||
|
target_prospective_item_id TEXT NOT NULL DEFAULT '',
|
||||||
|
target_external_ref TEXT NOT NULL DEFAULT '',
|
||||||
|
resolution_state TEXT NOT NULL,
|
||||||
|
outcome TEXT NOT NULL,
|
||||||
|
clarification TEXT NOT NULL DEFAULT '',
|
||||||
|
snooze_until_unix_millis BIGINT NOT NULL DEFAULT 0,
|
||||||
|
user_reported_not_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
recorded_at_unix_millis BIGINT NOT NULL,
|
||||||
|
revision BIGINT NOT NULL,
|
||||||
|
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CHECK (owner_principal_id <> ''),
|
||||||
|
CHECK (scope_id <> ''),
|
||||||
|
CHECK (source_utterance_id <> ''),
|
||||||
|
CHECK (source_intent_id <> ''),
|
||||||
|
CHECK (operation IN ('ACKNOWLEDGE','COMPLETE','SNOOZE','CANCEL','DISMISS','CONTINUE','REJECT','UNKNOWN')),
|
||||||
|
CHECK (target_kind IN ('ATTENTION_ITEM','PROSPECTIVE_ITEM','WORK_TASK','CAPABILITY_GAP','INTERACTION_CONTEXT','UNKNOWN')),
|
||||||
|
CHECK (resolution_state IN ('RESOLVED','AMBIGUOUS','NO_TARGET','NOOP')),
|
||||||
|
CHECK (outcome IN ('USER_REPORTED_COMPLETE','SNOOZED','CANCELLED','DISMISSED','CLARIFICATION_NEEDED','NOOP')),
|
||||||
|
CHECK (recorded_at_unix_millis > 0),
|
||||||
|
CHECK (revision > 0),
|
||||||
|
CHECK (operation <> 'SNOOZE' OR snooze_until_unix_millis > recorded_at_unix_millis),
|
||||||
|
CHECK (resolution_state <> 'RESOLVED' OR target_kind <> 'UNKNOWN')
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE continuity.attention_items
|
||||||
|
ADD COLUMN IF NOT EXISTS response_id TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS response_utterance_id TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS response_operation TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS response_outcome TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS responded_at_unix_millis BIGINT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
constraint_record RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR constraint_record IN
|
||||||
|
SELECT conname
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conrelid = 'continuity.attention_items'::regclass
|
||||||
|
AND contype = 'c'
|
||||||
|
AND pg_get_constraintdef(oid) LIKE '%state%'
|
||||||
|
AND pg_get_constraintdef(oid) LIKE '%DELIVERED%'
|
||||||
|
AND pg_get_constraintdef(oid) LIKE '%FAILED%'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE continuity.attention_items DROP CONSTRAINT %I', constraint_record.conname);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE continuity.attention_items
|
||||||
|
ADD CONSTRAINT attention_items_state_check
|
||||||
|
CHECK (state IN ('PENDING','DELIVERING','DELIVERED','RESOLVED','SUPPRESSED','EXPIRED','FAILED'));
|
||||||
|
|
||||||
|
ALTER TABLE continuity.attention_items
|
||||||
|
ADD CONSTRAINT attention_items_delivered_evidence_check
|
||||||
|
CHECK (state <> 'DELIVERED' OR (delivered_at_unix_millis > 0 AND action_intent_id <> '' AND authorized_action_id <> '' AND action_attempt_id <> '' AND delivery_outcome <> ''));
|
||||||
|
|
||||||
|
ALTER TABLE continuity.attention_items
|
||||||
|
ADD CONSTRAINT attention_items_response_projection_check
|
||||||
|
CHECK (
|
||||||
|
state <> 'RESOLVED'
|
||||||
|
OR (
|
||||||
|
response_id <> ''
|
||||||
|
AND response_utterance_id <> ''
|
||||||
|
AND response_operation IN ('ACKNOWLEDGE','COMPLETE','SNOOZE','CANCEL','DISMISS','CONTINUE','REJECT')
|
||||||
|
AND response_outcome IN ('USER_REPORTED_COMPLETE','SNOOZED','CANCELLED','DISMISSED','NOOP')
|
||||||
|
AND responded_at_unix_millis > 0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
RESET ROLE;
|
||||||
|
COMMIT;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"task_file": "dev/tasks/DEV-000065-assistant-presence-attention-human-surface.json",
|
"task_file": "dev/tasks/DEV-000066-human-response-continuation-semantics.json",
|
||||||
"task_id": "DEV-000065",
|
"task_id": "DEV-000066",
|
||||||
"task_revision": 1
|
"task_revision": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
{
|
||||||
|
"task_id": "DEV-000066",
|
||||||
|
"task_revision": 1,
|
||||||
|
"architecture_revision": "ADR-0015",
|
||||||
|
"base_repo_revision": "e8e51ffdba38ab901e275283e46f2b45bb6affe9",
|
||||||
|
"risk_tier": "E",
|
||||||
|
"intent": "Add provider-neutral human response and continuation semantics for existing context, with reminders/attention as the first implementation and no reminder-specific parallel subsystem.",
|
||||||
|
"requirement_ids": [
|
||||||
|
"ADR-0015",
|
||||||
|
"INV-AUTH-001",
|
||||||
|
"INV-OWN-001",
|
||||||
|
"INV-CONT-001",
|
||||||
|
"INV-EPI-001",
|
||||||
|
"Human response is not automatic world truth",
|
||||||
|
"Contextual response target resolution without internal IDs"
|
||||||
|
,
|
||||||
|
"ActiveProjectFocus is contextual evidence, not a global user mode"
|
||||||
|
],
|
||||||
|
"allowed_change_scope": {
|
||||||
|
"modules": [
|
||||||
|
"continuity",
|
||||||
|
"interaction"
|
||||||
|
],
|
||||||
|
"paths": [
|
||||||
|
"architecture/adrs/ADR-0015-human-response-continuation-semantics.md",
|
||||||
|
"architecture/architecture-registry.yaml",
|
||||||
|
"architecture/architecture-registry.json",
|
||||||
|
"contracts/continuity/**",
|
||||||
|
"contracts/interaction/**",
|
||||||
|
"modules/continuity/**",
|
||||||
|
"modules/interaction/**",
|
||||||
|
"adapters/postgres/**",
|
||||||
|
"database/bootstrap/**",
|
||||||
|
"database/migrations/**",
|
||||||
|
"cmd/continuity-runtime/**",
|
||||||
|
"cmd/core-runtime/**",
|
||||||
|
"cmd/creator-web/**",
|
||||||
|
"tests/owner_acl/**",
|
||||||
|
"tests/cognitive_advisory/**",
|
||||||
|
"docs/PROJECT_STATUS.md",
|
||||||
|
"dev/ACTIVE_TASK.json",
|
||||||
|
"dev/tasks/DEV-000066-human-response-continuation-semantics.json"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"forbidden_change_scope": {
|
||||||
|
"paths": [
|
||||||
|
"architecture/ARCHITECTURE_CONSTITUTION.md",
|
||||||
|
"modules/authority/**",
|
||||||
|
"modules/commit/**",
|
||||||
|
"cmd/commit-runtime/**",
|
||||||
|
"security/**",
|
||||||
|
"ci/**",
|
||||||
|
".gitea/**"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowed_architecture_delta": {
|
||||||
|
"new_cross_module_dependencies": 0,
|
||||||
|
"new_canonical_owners": 0,
|
||||||
|
"new_privileges": 0,
|
||||||
|
"new_third_party_dependencies": 0,
|
||||||
|
"new_provider_adapters": 0
|
||||||
|
},
|
||||||
|
"contracts": {
|
||||||
|
"consume": [
|
||||||
|
"Interaction HumanUtterance / OriginalHumanIntent",
|
||||||
|
"Continuity ProspectiveItem",
|
||||||
|
"Continuity AttentionItem"
|
||||||
|
],
|
||||||
|
"may_change": [
|
||||||
|
"contracts/continuity@v1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"semantic_owner": "continuity",
|
||||||
|
"canonical_state": [
|
||||||
|
"HumanResponse",
|
||||||
|
"ResponseTargetResolution",
|
||||||
|
"ProspectiveItem",
|
||||||
|
"AttentionItem"
|
||||||
|
],
|
||||||
|
"state_transitions": [
|
||||||
|
"single obvious delivered reminder attention + COMPLETE -> ProspectiveItem SATISFIED and Attention RESOLVED",
|
||||||
|
"single obvious delivered reminder attention + SNOOZE -> same ProspectiveItem ARMED at new due time and Attention RESOLVED",
|
||||||
|
"single obvious reminder/attention + CANCEL -> ProspectiveItem SUPPRESSED and Attention SUPPRESSED when present",
|
||||||
|
"ambiguous target -> clarification response record and no semantic mutation"
|
||||||
|
],
|
||||||
|
"side_effects_and_privileges": [
|
||||||
|
"No external mutation is introduced",
|
||||||
|
"Human responses mutate only Continuity-owned state through Continuity runtime",
|
||||||
|
"User-reported completion is recorded as USER_REPORTED_COMPLETE, not verified world truth",
|
||||||
|
"No cognitive model is required for obvious deterministic response operations",
|
||||||
|
"Docker-test remains prohibited"
|
||||||
|
],
|
||||||
|
"persistence_and_migrations": [
|
||||||
|
"continuity.human_responses append-only table",
|
||||||
|
"Attention response closure fields",
|
||||||
|
"no cross-owner FK",
|
||||||
|
"no cross-owner SQL join dependency"
|
||||||
|
],
|
||||||
|
"failure_retry_restart": [
|
||||||
|
"HumanResponse rows are append-only audit/history",
|
||||||
|
"Ambiguous target resolution fails closed with clarification",
|
||||||
|
"Snoozed reminders are re-armed in canonical prospective state and survive restart",
|
||||||
|
"Cancelled/suppressed reminders are excluded by the existing due scanner"
|
||||||
|
],
|
||||||
|
"assumptions": [
|
||||||
|
"DEV-000065 is production baseline",
|
||||||
|
"Continuity remains canonical owner for future obligation continuation",
|
||||||
|
"Interaction remains canonical owner of utterances and original human intent",
|
||||||
|
"Buttons are optional UI projection over the same chat response endpoint"
|
||||||
|
],
|
||||||
|
"acceptance_scenarios": [
|
||||||
|
"Single active reminder plus 'Готово' resolves the correct target",
|
||||||
|
"Single active reminder plus 'Отложи на час' reschedules the same prospective item",
|
||||||
|
"'Отмени' suppresses the reminder and prevents future delivery",
|
||||||
|
"Two possible targets plus ambiguous 'Отмени' asks clarification and does not mutate",
|
||||||
|
"Historical utterances remain append-only",
|
||||||
|
"Attention closes appropriately",
|
||||||
|
"User-reported completion does not become verified world fact",
|
||||||
|
"Obvious response operations work LLM-free",
|
||||||
|
"Cognition unavailable does not break obvious operations",
|
||||||
|
"UI buttons and natural language use the same backend semantics",
|
||||||
|
"Future non-reminder response target kinds can be represented"
|
||||||
|
,
|
||||||
|
"Unrelated new human intents do not inherit stale ActiveProjectFocus",
|
||||||
|
"Human UI does not expose technical project provenance labels"
|
||||||
|
],
|
||||||
|
"required_tests": [
|
||||||
|
"go test ./...",
|
||||||
|
"go vet ./...",
|
||||||
|
"go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json",
|
||||||
|
"go run ./cmd/devctl check-architecture",
|
||||||
|
"go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json",
|
||||||
|
"tests/cognitive_advisory/cognitive_worker_env_policy_test.ps1",
|
||||||
|
"tests/owner_acl/verify.ps1 -DockerContext docker-prod -PostgresContainer assistant-postgres-1",
|
||||||
|
"production live complete/snooze/cancel acceptance",
|
||||||
|
"production /chat and /today verification"
|
||||||
|
],
|
||||||
|
"terminal_gap_states": [
|
||||||
|
"BLOCKED_BY_ARCHITECTURE_GAP",
|
||||||
|
"BLOCKED_BY_SPEC_GAP",
|
||||||
|
"BLOCKED_BY_MISSING_CAPABILITY",
|
||||||
|
"BLOCKED_BY_CONFLICT"
|
||||||
|
]
|
||||||
|
}
|
||||||
+18
-1
@@ -1,6 +1,23 @@
|
|||||||
# Assistant project status
|
# Assistant project status
|
||||||
|
|
||||||
Audit date: 2026-08-17. Active implementation: DEV-000065 revision 1.
|
Audit date: 2026-08-17. Active implementation: DEV-000066 revision 1.
|
||||||
|
|
||||||
|
## DEV-000066 checkpoint
|
||||||
|
|
||||||
|
- `DEV_000066_STATUS = IMPLEMENTED_NOT_PROMOTED`
|
||||||
|
- `LAST_COMPLETED = ADR-0015 accepted; Continuity-owned HumanResponse contract foundation; ResponseTargetResolution semantics; append-only human_responses migration; Attention response projection fields; deterministic COMPLETE/SNOOZE/CANCEL/DISMISS interpretation; Creator Web response buttons over ordinary /chat; ActiveProjectFocus leak regression under Interaction semantics`
|
||||||
|
- `ADR_STATUS = ADR-0015 accepted`
|
||||||
|
- `OWNERSHIP_STATUS = no new owner; HumanResponse and response target resolution belong to Continuity; Interaction remains utterance/original-intent owner`
|
||||||
|
- `REMINDER_RESPONSE_STATUS = COMPLETE records USER_REPORTED_COMPLETE, SNOOZE rearms same ProspectiveItem, CANCEL suppresses reminder; ambiguous target asks clarification`
|
||||||
|
- `CONTEXT_STATUS = ActiveProjectFocus treated as contextual evidence, not global mode; unrelated new intents do not inherit stale project focus`
|
||||||
|
- `HUMAN_SURFACE_STATUS = /chat remains primary; Today buttons send natural language through the same backend semantics`
|
||||||
|
- `AUTHORITY_STATUS = no new external mutation authority; human reported completion is not verified world truth`
|
||||||
|
- `LLM_FREE_STATUS = obvious response operations are deterministic and do not require cognitive model`
|
||||||
|
- `TESTS_PASS = go test ./...; go vet ./...; go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json; go run ./cmd/devctl check-architecture; go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json; tests/cognitive_advisory/cognitive_worker_env_policy_test.ps1; tests/owner_acl/verify.ps1 -DockerContext docker-prod -PostgresContainer assistant-postgres-1`
|
||||||
|
- `LIVE_ACCEPTANCE = pending production deployment`
|
||||||
|
- `PRODUCTION_STATUS = pending promotion/deployment`
|
||||||
|
- `ACTIVE_COGNITIVE_BINDINGS = none; CPU CANARY unchanged`
|
||||||
|
- `OPENAI_REAL_CREDENTIAL = not required / not activated`
|
||||||
|
|
||||||
## DEV-000065 checkpoint
|
## DEV-000065 checkpoint
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
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 {
|
||||||
|
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]
|
||||||
|
response.TargetKind = contract.ResponseTargetAttentionItem
|
||||||
|
response.TargetAttentionID = candidate.Attention.ID
|
||||||
|
response.TargetProspectiveID = candidate.Attention.SourceProspectiveID
|
||||||
|
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 = "ожидание"
|
||||||
|
}
|
||||||
|
parts = append(parts, text)
|
||||||
|
}
|
||||||
|
if len(parts) > 3 {
|
||||||
|
parts = parts[:3]
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/authority"
|
||||||
|
"git.cin.su/m/Assistant/contracts/continuity"
|
||||||
|
"git.cin.su/m/Assistant/contracts/identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInterpretHumanResponseCompletesSingleDeliveredReminder(t *testing.T) {
|
||||||
|
result := interpretResponseForTest(t, "Готово", []ResponseCandidate{responseCandidate("a1", "p1", "позвонить врачу")})
|
||||||
|
if !result.Handled || result.Response.Operation != continuity.ResponseComplete || result.Response.Outcome != continuity.ResponseOutcomeUserReportedComplete || !result.Response.UserReportedNotVerified {
|
||||||
|
t.Fatalf("unexpected complete response: %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInterpretHumanResponseSnoozesSingleReminder(t *testing.T) {
|
||||||
|
result := interpretResponseForTest(t, "Отложи на 10 минут", []ResponseCandidate{responseCandidate("a1", "p1", "проверить базу")})
|
||||||
|
if !result.Handled || result.Response.Operation != continuity.ResponseSnooze || result.Response.Outcome != continuity.ResponseOutcomeSnoozed {
|
||||||
|
t.Fatalf("unexpected snooze response: %#v", result)
|
||||||
|
}
|
||||||
|
if result.Response.SnoozeUntilUnixMillis != time.Unix(100, 0).Add(10*time.Minute).UnixMilli() {
|
||||||
|
t.Fatalf("unexpected snooze time: %d", result.Response.SnoozeUntilUnixMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInterpretHumanResponseCancelsSingleReminder(t *testing.T) {
|
||||||
|
result := interpretResponseForTest(t, "Отмени", []ResponseCandidate{responseCandidate("a1", "p1", "встретить курьера")})
|
||||||
|
if !result.Handled || result.Response.Operation != continuity.ResponseCancel || result.Response.Outcome != continuity.ResponseOutcomeCancelled {
|
||||||
|
t.Fatalf("unexpected cancel response: %#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, "несколько") {
|
||||||
|
t.Fatalf("expected ambiguity clarification, 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 {
|
||||||
|
t.Fatalf("expected no-target clarification, got %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func interpretResponseForTest(t *testing.T, message string, candidates []ResponseCandidate) continuity.HumanResponseResult {
|
||||||
|
t.Helper()
|
||||||
|
result, err := InterpretHumanResponse(continuity.HumanResponseRequest{
|
||||||
|
SourceUtteranceID: "utterance-1",
|
||||||
|
SourceIntentID: "intent-1",
|
||||||
|
Message: message,
|
||||||
|
RequestedAtUnixMillis: time.Unix(100, 0).UnixMilli(),
|
||||||
|
}, identity.PrincipalID("principal-1"), authority.ScopeID("scope-1"), continuity.HumanResponseID("response-1"), candidates, time.Unix(100, 0))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseCandidate(attentionID continuity.AttentionItemID, prospectiveID continuity.ProspectiveItemID, summary string) ResponseCandidate {
|
||||||
|
return ResponseCandidate{
|
||||||
|
Attention: continuity.AttentionItem{
|
||||||
|
ID: attentionID,
|
||||||
|
OwnerPrincipalID: "principal-1",
|
||||||
|
ScopeID: "scope-1",
|
||||||
|
SourceKind: continuity.AttentionSourceProspectiveReminder,
|
||||||
|
SourceProspectiveID: prospectiveID,
|
||||||
|
State: continuity.AttentionDelivered,
|
||||||
|
Summary: summary,
|
||||||
|
Revision: 1,
|
||||||
|
},
|
||||||
|
Prospective: continuity.ProspectiveItem{
|
||||||
|
ID: prospectiveID,
|
||||||
|
OwnerPrincipalID: "principal-1",
|
||||||
|
ScopeID: "scope-1",
|
||||||
|
Summary: summary,
|
||||||
|
State: continuity.ProspectiveDelivered,
|
||||||
|
Revision: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,7 +112,7 @@ func projectReferences(utterance interaction.HumanUtterance, projects []situatio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(refs) == 0 && previousFocus != nil && strings.Contains(lower, "туда") {
|
if len(refs) == 0 && previousFocus != nil && inheritedFocusAllowed(utterance.SpeechAct, utterance.Content) {
|
||||||
ref, err := newProjectReference(utterance.ID, previousFocus.ProjectID, previousFocus.ProjectDisplayName, interaction.ProjectRolePrimary, situation.ProjectResolutionInheritedFocus, 700, millis)
|
ref, err := newProjectReference(utterance.ID, previousFocus.ProjectID, previousFocus.ProjectDisplayName, interaction.ProjectRolePrimary, situation.ProjectResolutionInheritedFocus, 700, millis)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -196,7 +196,17 @@ func requestedOutcome(message string, act interaction.SpeechAct) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func inheritedFocusAllowed(act interaction.SpeechAct, message string) bool {
|
func inheritedFocusAllowed(act interaction.SpeechAct, message string) bool {
|
||||||
return strings.Contains(strings.ToLower(message), "туда") && act != interaction.SpeechActUnknown
|
lower := strings.ToLower(message)
|
||||||
|
if act == interaction.SpeechActUnknown {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.Contains(lower, "туда") || strings.Contains(lower, "там") || strings.Contains(lower, "в этом проекте") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if act == interaction.SpeechActQuestion || act == interaction.SpeechActRequestToInvestigate {
|
||||||
|
return strings.Contains(lower, "база") || strings.Contains(lower, "сервер") || strings.Contains(lower, "почему тормозит") || strings.Contains(lower, "что там")
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func isAmbiguousDestructive(act interaction.SpeechAct, message string, projects []interaction.ProjectReference) bool {
|
func isAmbiguousDestructive(act interaction.SpeechAct, message string, projects []interaction.ProjectReference) bool {
|
||||||
|
|||||||
@@ -73,6 +73,25 @@ func TestInterpretUtteranceInheritedFocusForThere(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProjectFocusIsContextualEvidenceNotGlobalMode(t *testing.T) {
|
||||||
|
explicit := interpretForTest(t, "Сейчас работаем с УПО.", nil, "")
|
||||||
|
if explicit.ActiveFocus == nil || explicit.ActiveFocus.ProjectID != "upo" {
|
||||||
|
t.Fatalf("expected explicit UPO focus, got %#v", explicit.ActiveFocus)
|
||||||
|
}
|
||||||
|
focusedQuestion := interpretForTest(t, "Почему тормозит база?", activeFocus("upo", "УПО"), "")
|
||||||
|
if len(focusedQuestion.Projects) != 1 || focusedQuestion.Projects[0].ProjectID != "upo" || focusedQuestion.Projects[0].Provenance != situation.ProjectResolutionInheritedFocus {
|
||||||
|
t.Fatalf("database follow-up can use previous project as contextual evidence, got %#v", focusedQuestion.Projects)
|
||||||
|
}
|
||||||
|
unrelated := interpretForTest(t, "Помоги ответить на письмо бухгалтеру.", activeFocus("upo", "УПО"), "")
|
||||||
|
if len(unrelated.Projects) != 0 {
|
||||||
|
t.Fatalf("unrelated mail intent must not inherit UPO focus, got %#v", unrelated.Projects)
|
||||||
|
}
|
||||||
|
returned := interpretForTest(t, "Вернемся к УПО.", activeFocus("assistant", "Assistant"), "")
|
||||||
|
if returned.ActiveFocus == nil || returned.ActiveFocus.ProjectID != "upo" {
|
||||||
|
t.Fatalf("expected explicit return to UPO, got %#v", returned.ActiveFocus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInterpretUtteranceMultiProjectReferenceRoles(t *testing.T) {
|
func TestInterpretUtteranceMultiProjectReferenceRoles(t *testing.T) {
|
||||||
result := interpretForTest(t, "Посмотри, как это сделано в Assistant, и сделай аналогично в УПО.", nil, "")
|
result := interpretForTest(t, "Посмотри, как это сделано в Assistant, и сделай аналогично в УПО.", nil, "")
|
||||||
if len(result.Projects) != 2 {
|
if len(result.Projects) != 2 {
|
||||||
|
|||||||
Reference in New Issue
Block a user