From 8b9b841645ab6e44dd02114c386ae09a960fc57b Mon Sep 17 00:00:00 2001 From: Mikhail Date: Mon, 17 Aug 2026 17:02:33 +0300 Subject: [PATCH] DEV-000066 human response continuation semantics --- adapters/postgres/continuity_repository.go | 193 ++++++++++++++++-- ...5-human-response-continuation-semantics.md | 42 ++++ architecture/architecture-registry.json | 3 +- architecture/architecture-registry.yaml | 2 +- cmd/continuity-runtime/attention.go | 13 ++ cmd/continuity-runtime/human_response.go | 66 ++++++ cmd/continuity-runtime/human_response_test.go | 80 ++++++++ cmd/continuity-runtime/main.go | 1 + cmd/core-runtime/capability_chat.go | 44 ++++ cmd/core-runtime/main.go | 21 +- cmd/creator-web/static/assets/chat.css | 3 + cmd/creator-web/static/assets/chat.js | 67 ++++-- contracts/continuity/types.go | 86 ++++++++ database/bootstrap/001_roles_and_schemas.sql | 47 ++++- .../022_continuity_human_responses.sql | 84 ++++++++ dev/ACTIVE_TASK.json | 4 +- ...human-response-continuation-semantics.json | 144 +++++++++++++ docs/PROJECT_STATUS.md | 19 +- modules/continuity/domain/human_response.go | 170 +++++++++++++++ .../continuity/domain/human_response_test.go | 86 ++++++++ modules/interaction/domain/semantics.go | 14 +- modules/interaction/domain/semantics_test.go | 19 ++ 22 files changed, 1163 insertions(+), 45 deletions(-) create mode 100644 architecture/adrs/ADR-0015-human-response-continuation-semantics.md create mode 100644 cmd/continuity-runtime/human_response.go create mode 100644 cmd/continuity-runtime/human_response_test.go create mode 100644 database/migrations/022_continuity_human_responses.sql create mode 100644 dev/tasks/DEV-000066-human-response-continuation-semantics.json create mode 100644 modules/continuity/domain/human_response.go create mode 100644 modules/continuity/domain/human_response_test.go diff --git a/adapters/postgres/continuity_repository.go b/adapters/postgres/continuity_repository.go index df53232..b77ad46 100644 --- a/adapters/postgres/continuity_repository.go +++ b/adapters/postgres/continuity_repository.go @@ -9,6 +9,7 @@ import ( "git.cin.su/m/Assistant/contracts/identity" continuitydomain "git.cin.su/m/Assistant/modules/continuity/domain" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "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 ( 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) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,0,'','','','',1) - ON CONFLICT (source_kind, source_prospective_item_id) DO UPDATE SET source_prospective_item_id=EXCLUDED.source_prospective_item_id + 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,'','','','','','','','',0,1) + 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, - 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.Level, attention.State, attention.Summary, attention.DueAtUnixMillis, attention.CreatedAtUnixMillis).Scan( &attention.ID, &attention.OwnerPrincipalID, &attention.ScopeID, &attention.SourceKind, &attention.SourceProspectiveID, &attention.Level, &attention.State, &attention.Summary, &attention.DueAtUnixMillis, &attention.CreatedAtUnixMillis, &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 { 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") } 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) if err != nil { 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") } 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) if err != nil { 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) } +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) { var item continuity.AttentionItem 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 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( &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.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) { return continuity.AttentionItem{}, ErrAttentionRevisionConflict } @@ -216,12 +356,14 @@ func (r *ContinuityRepository) MarkAttentionDelivered(ctx context.Context, id co action_attempt_id=$5, delivery_outcome=$6 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, - 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, id, continuity.AttentionDelivering, revision).Scan( &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.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) { return continuity.AttentionItem{}, ErrAttentionRevisionConflict } @@ -235,12 +377,14 @@ func (r *ContinuityRepository) MarkAttentionFailed(ctx context.Context, id conti action_attempt_id=$4, delivery_outcome=$5 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, - 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, id, continuity.AttentionDelivering, revision).Scan( &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.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) { return continuity.AttentionItem{}, ErrAttentionRevisionConflict } @@ -316,12 +460,32 @@ type continuityRowQuerier interface { QueryRow(context.Context, string, ...any) pgx.Row } +type continuityExecutor interface { + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) +} + type continuityRows interface { Next() bool Scan(...any) 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) { items := make([]continuity.AttentionItem, 0) 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, &item.Level, &item.State, &item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis, &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) } items = append(items, item) diff --git a/architecture/adrs/ADR-0015-human-response-continuation-semantics.md b/architecture/adrs/ADR-0015-human-response-continuation-semantics.md new file mode 100644 index 0000000..494389a --- /dev/null +++ b/architecture/adrs/ADR-0015-human-response-continuation-semantics.md @@ -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. diff --git a/architecture/architecture-registry.json b/architecture/architecture-registry.json index 64eac60..07174f0 100644 --- a/architecture/architecture-registry.json +++ b/architecture/architecture-registry.json @@ -99,7 +99,8 @@ "owns": [ "Commitment", "ProspectiveItem", - "AttentionItem" + "AttentionItem", + "HumanResponse" ], "database_schema": "continuity", "boundary_class": "remote_capable", diff --git a/architecture/architecture-registry.yaml b/architecture/architecture-registry.yaml index 516a28a..b0412e9 100644 --- a/architecture/architecture-registry.yaml +++ b/architecture/architecture-registry.yaml @@ -41,7 +41,7 @@ modules: continuity: id: mod.continuity - owns: [Commitment, ProspectiveItem, AttentionItem] + owns: [Commitment, ProspectiveItem, AttentionItem, HumanResponse] database_schema: continuity boundary_class: remote_capable may_depend_on: [contracts.work, contracts.identity, contracts.authority, platform.events] diff --git a/cmd/continuity-runtime/attention.go b/cmd/continuity-runtime/attention.go index b0accd2..39bf5c1 100644 --- a/cmd/continuity-runtime/attention.go +++ b/cmd/continuity-runtime/attention.go @@ -54,6 +54,7 @@ func registerCreatorAttentionRoutes(mux *http.ServeMux, identityClient creatorId HumanState: humanAttentionState(item.State), DeliveryState: humanDeliveryState(item), SourceDescription: humanAttentionSource(item.SourceKind), + ResponseHint: humanResponseHint(item), }) } w.Header().Set("Content-Type", "application/json") @@ -83,6 +84,8 @@ func humanAttentionState(state continuity.AttentionState) string { return "доставляется" case continuity.AttentionDelivered: return "доставлено" + case continuity.AttentionResolved: + return "закрыто" case continuity.AttentionSuppressed: return "скрыто" 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 { if item.State == continuity.AttentionDelivered { return "уведомление отправлено" diff --git a/cmd/continuity-runtime/human_response.go b/cmd/continuity-runtime/human_response.go new file mode 100644 index 0000000..9e4bdcb --- /dev/null +++ b/cmd/continuity-runtime/human_response.go @@ -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) + }) +} diff --git a/cmd/continuity-runtime/human_response_test.go b/cmd/continuity-runtime/human_response_test.go new file mode 100644 index 0000000..da98e85 --- /dev/null +++ b/cmd/continuity-runtime/human_response_test.go @@ -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 +} diff --git a/cmd/continuity-runtime/main.go b/cmd/continuity-runtime/main.go index 62f46de..03f79b7 100644 --- a/cmd/continuity-runtime/main.go +++ b/cmd/continuity-runtime/main.go @@ -43,6 +43,7 @@ func main() { defer cancelWorker() dueReminderWorker{repository: repository, commit: commitClient, now: time.Now, newID: ids.New, interval: 5 * time.Second, limit: 10}.Start(workerContext) registerInternalExecutorRoutes(mux, repository, executorToken) + registerCreatorResponseRoute(mux, identityClient, repository, time.Now) mux.HandleFunc("POST /v1/prospective-items", func(w http.ResponseWriter, r *http.Request) { var item continuity.ProspectiveItem if err := json.NewDecoder(r.Body).Decode(&item); err != nil { diff --git a/cmd/core-runtime/capability_chat.go b/cmd/core-runtime/capability_chat.go index df00f28..c5759bc 100644 --- a/cmd/core-runtime/capability_chat.go +++ b/cmd/core-runtime/capability_chat.go @@ -28,6 +28,11 @@ type continuityReminderClient struct { client *http.Client } +type continuityResponseClient struct { + baseURL *url.URL + client *http.Client +} + func newContinuityReminderClient(rawURL string, client *http.Client) (continuityReminderClient, error) { base, err := url.Parse(rawURL) 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 } +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) { target := *c.baseURL target.Path = "/v1/creator/reminders" @@ -70,6 +86,34 @@ func (c continuityReminderClient) Schedule(ctx context.Context, authorization st 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) { requirementID, err := ids.New() if err != nil { diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index 569bd96..93b70f3 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -90,6 +90,10 @@ func main() { if err != nil { 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() authenticateCreator := func(w http.ResponseWriter, r *http.Request) (identity.CreatorSession, bool) { session, err := identitydomain.ValidateCreatorSession(sessionKey, bearerToken(r), time.Now()) @@ -235,6 +239,12 @@ func main() { http.Error(w, "save chat semantics", http.StatusInternalServerError) 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 := "" activeFocusProvenance := situation.ProjectResolutionProvenance("") if semantic.ActiveFocus != nil { @@ -244,7 +254,7 @@ func main() { } activeFocusName = semantic.ActiveFocus.ProjectDisplayName 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 activeFocusProvenance = previousFocus.Provenance } @@ -254,9 +264,13 @@ func main() { projectID := "" if semantic.ActiveFocus != nil { 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) } + if responseErr == nil && responseResult.Handled { + planningReply = responseResult.Reply + needsCognition = false + } planningInput := work.PlanningInput{ ConversationID: work.ConversationRef(conversation.ID), UtteranceID: work.SourceUtteranceRef(utterance.ID), @@ -273,6 +287,9 @@ func main() { ReceivedAtUnixMillis: utterance.ReceivedAtUnixMillis, } helpPath := workdomain.DetermineHelpPath(planningInput) + if responseErr == nil && responseResult.Handled { + helpPath = work.HelpPath{} + } if isCapabilityQuestion(utterance.Content) { kinds, err := capabilityRepository.AvailableCapabilityKinds(r.Context(), session.PrincipalID) if err == nil && len(kinds) > 0 { diff --git a/cmd/creator-web/static/assets/chat.css b/cmd/creator-web/static/assets/chat.css index 4c703c7..a1c36df 100644 --- a/cmd/creator-web/static/assets/chat.css +++ b/cmd/creator-web/static/assets/chat.css @@ -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 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-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 strong { display: block; margin-bottom: 8px; color: var(--signal); } .empty-state { margin: 24px 0 0; color: var(--muted); } diff --git a/cmd/creator-web/static/assets/chat.js b/cmd/creator-web/static/assets/chat.js index b50f58c..1fb07d6 100644 --- a/cmd/creator-web/static/assets/chat.js +++ b/cmd/creator-web/static/assets/chat.js @@ -48,7 +48,8 @@ function appendMessage(kind, text, meta = "") { function updateActiveFocus(value, provenance = "") { if (!activeFocus) return; - activeFocus.textContent = value ? `фокус: ${value}${provenance ? ` · ${provenance}` : ""}` : "фокус: не выбран"; + activeFocus.textContent = value ? `Сейчас обсуждаем: ${value}` : ""; + activeFocus.hidden = !value; } async function api(path, options = {}) { @@ -121,7 +122,27 @@ async function loadToday() { state.textContent = item.HumanState || item.human_state || "ожидает внимания"; const source = document.createElement("small"); 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); } } 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) { for (const tab of navTabs) tab.classList.toggle("active", tab.dataset.tab === panelID); for (const panel of document.querySelectorAll(".surface-panel")) { @@ -163,25 +207,8 @@ chatForm.addEventListener("submit", async (event) => { event.preventDefault(); const text = messageInput.value.trim(); if (!text) return; - appendMessage("user", text); messageInput.value = ""; - sendButton.disabled = true; - 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(); - } + await sendChatMessage(text); }); for (const tab of navTabs) { diff --git a/contracts/continuity/types.go b/contracts/continuity/types.go index bae1d9f..b33c12b 100644 --- a/contracts/continuity/types.go +++ b/contracts/continuity/types.go @@ -15,6 +15,8 @@ type AttentionRevision uint64 type CommittedActionIntentID ids.ID type CommittedAuthorizedActionID ids.ID type CommittedActionAttemptID ids.ID +type HumanResponseID ids.ID +type HumanResponseRevision uint64 type ProspectiveState string @@ -46,6 +48,7 @@ const ( AttentionPending AttentionState = "PENDING" AttentionDelivering AttentionState = "DELIVERING" AttentionDelivered AttentionState = "DELIVERED" + AttentionResolved AttentionState = "RESOLVED" AttentionSuppressed AttentionState = "SUPPRESSED" AttentionExpired AttentionState = "EXPIRED" AttentionFailed AttentionState = "FAILED" @@ -106,6 +109,11 @@ type AttentionItem struct { AuthorizedActionID CommittedAuthorizedActionID ActionAttemptID CommittedActionAttemptID DeliveryOutcome string + ResponseID HumanResponseID + ResponseUtteranceID string + ResponseOperation HumanResponseOperation + ResponseOutcome ResponseOutcome + RespondedAtUnixMillis int64 Revision AttentionRevision } @@ -121,12 +129,90 @@ type TodayItem struct { HumanState string DeliveryState string SourceDescription string + ResponseHint string } type TodayResult struct { 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 // is distinct from a Work TaskTarget, which remains the live justification. type SatisfyDeliveredSubject struct { diff --git a/database/bootstrap/001_roles_and_schemas.sql b/database/bootstrap/001_roles_and_schemas.sql index 44c42fe..c19f424 100644 --- a/database/bootstrap/001_roles_and_schemas.sql +++ b/database/bootstrap/001_roles_and_schemas.sql @@ -594,17 +594,60 @@ CREATE TABLE IF NOT EXISTS continuity.attention_items ( authorized_action_id TEXT NOT NULL DEFAULT '', action_attempt_id 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, recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE (source_kind, source_prospective_item_id), CHECK (source_kind IN ('PROSPECTIVE_REMINDER')), 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 (due_at_unix_millis > 0), CHECK (created_at_unix_millis > 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; diff --git a/database/migrations/022_continuity_human_responses.sql b/database/migrations/022_continuity_human_responses.sql new file mode 100644 index 0000000..093ddeb --- /dev/null +++ b/database/migrations/022_continuity_human_responses.sql @@ -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; diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index cd6c2d7..e33a7f7 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000065-assistant-presence-attention-human-surface.json", - "task_id": "DEV-000065", + "task_file": "dev/tasks/DEV-000066-human-response-continuation-semantics.json", + "task_id": "DEV-000066", "task_revision": 1 } diff --git a/dev/tasks/DEV-000066-human-response-continuation-semantics.json b/dev/tasks/DEV-000066-human-response-continuation-semantics.json new file mode 100644 index 0000000..e54fcc9 --- /dev/null +++ b/dev/tasks/DEV-000066-human-response-continuation-semantics.json @@ -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" + ] +} diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index e6cafa2..9bb69f4 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -1,6 +1,23 @@ # 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 diff --git a/modules/continuity/domain/human_response.go b/modules/continuity/domain/human_response.go new file mode 100644 index 0000000..e2ae04c --- /dev/null +++ b/modules/continuity/domain/human_response.go @@ -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 +} diff --git a/modules/continuity/domain/human_response_test.go b/modules/continuity/domain/human_response_test.go new file mode 100644 index 0000000..f22b2c3 --- /dev/null +++ b/modules/continuity/domain/human_response_test.go @@ -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, + }, + } +} diff --git a/modules/interaction/domain/semantics.go b/modules/interaction/domain/semantics.go index c320ac4..793a6a0 100644 --- a/modules/interaction/domain/semantics.go +++ b/modules/interaction/domain/semantics.go @@ -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) if err != nil { return nil, err @@ -196,7 +196,17 @@ func requestedOutcome(message string, act interaction.SpeechAct) string { } 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 { diff --git a/modules/interaction/domain/semantics_test.go b/modules/interaction/domain/semantics_test.go index dee6790..73802e6 100644 --- a/modules/interaction/domain/semantics_test.go +++ b/modules/interaction/domain/semantics_test.go @@ -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) { result := interpretForTest(t, "Посмотри, как это сделано в Assistant, и сделай аналогично в УПО.", nil, "") if len(result.Projects) != 2 { -- 2.54.0