From 9a84ac942c4c5f30809bd0920273d9e0a9b25c6b Mon Sep 17 00:00:00 2001 From: Mikhail Date: Mon, 17 Aug 2026 15:51:25 +0300 Subject: [PATCH] DEV-000064 capability resolution foundation --- adapters/postgres/capability_repository.go | 112 ++++++++++ adapters/postgres/continuity_repository.go | 12 +- ...ity-resolution-selection-gap-foundation.md | 33 +++ architecture/architecture-registry.json | 8 +- architecture/architecture-registry.yaml | 2 +- cmd/continuity-runtime/main.go | 1 + cmd/continuity-runtime/reminder.go | 55 +++++ cmd/continuity-runtime/reminder_test.go | 46 +++++ cmd/core-runtime/capability_chat.go | 194 ++++++++++++++++++ cmd/core-runtime/capability_chat_test.go | 59 ++++++ cmd/core-runtime/main.go | 90 ++++++++ cmd/creator-web/chat_test.go | 34 +++ cmd/creator-web/server.go | 5 +- cmd/creator-web/static/assets/chat.js | 2 + contracts/capability/resolution.go | 162 +++++++++++++++ contracts/continuity/types.go | 17 ++ contracts/interaction/types.go | 7 + database/bootstrap/001_roles_and_schemas.sql | 131 +++++++++++- ...18_capability_resolution_selection_gap.sql | 139 +++++++++++++ .../019_continuity_reminder_metadata.sql | 20 ++ dev/ACTIVE_TASK.json | 4 +- ...y-resolution-selection-gap-foundation.json | 161 +++++++++++++++ docs/PROJECT_STATUS.md | 16 +- modules/capability/domain/resolution.go | 192 +++++++++++++++++ modules/capability/domain/resolution_test.go | 98 +++++++++ modules/continuity/domain/prospective.go | 22 ++ modules/work/domain/planning.go | 2 + 27 files changed, 1611 insertions(+), 13 deletions(-) create mode 100644 architecture/adrs/ADR-0013-capability-resolution-selection-gap-foundation.md create mode 100644 cmd/continuity-runtime/reminder.go create mode 100644 cmd/continuity-runtime/reminder_test.go create mode 100644 cmd/core-runtime/capability_chat.go create mode 100644 cmd/core-runtime/capability_chat_test.go create mode 100644 contracts/capability/resolution.go create mode 100644 database/migrations/018_capability_resolution_selection_gap.sql create mode 100644 database/migrations/019_continuity_reminder_metadata.sql create mode 100644 dev/tasks/DEV-000064-capability-resolution-selection-gap-foundation.json create mode 100644 modules/capability/domain/resolution.go create mode 100644 modules/capability/domain/resolution_test.go diff --git a/adapters/postgres/capability_repository.go b/adapters/postgres/capability_repository.go index 658bc4d..fa7549f 100644 --- a/adapters/postgres/capability_repository.go +++ b/adapters/postgres/capability_repository.go @@ -14,6 +14,7 @@ import ( var ErrCapabilityHealthNotFound = errors.New("capability health not found") var ErrCapabilityBindingNotFound = errors.New("capability binding not found") var ErrCapabilityBindingConflict = errors.New("capability binding conflict") +var ErrCapabilityImplementationNotFound = errors.New("capability implementation not found") type CapabilityRepository struct{ pool *pgxpool.Pool } @@ -106,6 +107,117 @@ func (r *CapabilityRepository) SaveBinding(ctx context.Context, binding capabili return tx.Commit(ctx) } +func (r *CapabilityRepository) SaveCapabilityImplementation(ctx context.Context, value capability.CapabilityImplementation) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + _, err = tx.Exec(ctx, `INSERT INTO capability.capability_implementations ( + implementation_id,capability_id,capability_kind,owner_principal_id,revision,implementation_kind,target_ref,credential_ref, + qualification,activation,health,authority_policy_ref,data_constraints,cost_score_millis,latency_score_millis,reliability_millis,observed_at_unix_millis) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) ON CONFLICT DO NOTHING`, + value.ID, value.CapabilityID, value.CapabilityKind, value.OwnerPrincipalID, value.Revision, value.Kind, value.TargetRef, + value.CredentialRef, value.Qualification, value.Activation, value.Health, value.AuthorityPolicyRef, value.DataConstraints, + value.CostScoreMillis, value.LatencyScoreMillis, value.ReliabilityMillis, value.ObservedAtUnixMillis) + if err != nil { + return err + } + _, err = tx.Exec(ctx, `INSERT INTO capability.capability_implementation_heads(implementation_id,revision,capability_kind,owner_principal_id) + VALUES ($1,$2,$3,$4) + ON CONFLICT (implementation_id) DO UPDATE SET revision=EXCLUDED.revision,capability_kind=EXCLUDED.capability_kind,owner_principal_id=EXCLUDED.owner_principal_id,updated_at=CURRENT_TIMESTAMP + WHERE capability.capability_implementation_heads.revision < EXCLUDED.revision`, value.ID, value.Revision, value.CapabilityKind, value.OwnerPrincipalID) + if err != nil { + return err + } + return tx.Commit(ctx) +} + +func (r *CapabilityRepository) LoadCapabilityImplementations(ctx context.Context, kind capability.CapabilityKind, owner identity.PrincipalID) ([]capability.CapabilityImplementation, error) { + rows, err := r.pool.Query(ctx, `SELECT + i.implementation_id,i.capability_id,i.capability_kind,i.owner_principal_id,i.revision,i.implementation_kind,i.target_ref,COALESCE(i.credential_ref,''), + i.qualification,i.activation,i.health,i.authority_policy_ref,i.data_constraints,i.cost_score_millis,i.latency_score_millis,i.reliability_millis,i.observed_at_unix_millis + FROM capability.capability_implementation_heads h + JOIN capability.capability_implementations i ON i.implementation_id=h.implementation_id AND i.revision=h.revision + WHERE i.capability_kind=$1 AND i.owner_principal_id=$2 + ORDER BY i.implementation_id`, kind, owner) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]capability.CapabilityImplementation, 0) + for rows.Next() { + var value capability.CapabilityImplementation + if err = rows.Scan(&value.ID, &value.CapabilityID, &value.CapabilityKind, &value.OwnerPrincipalID, &value.Revision, &value.Kind, &value.TargetRef, + &value.CredentialRef, &value.Qualification, &value.Activation, &value.Health, &value.AuthorityPolicyRef, &value.DataConstraints, + &value.CostScoreMillis, &value.LatencyScoreMillis, &value.ReliabilityMillis, &value.ObservedAtUnixMillis); err != nil { + return nil, err + } + result = append(result, value) + } + return result, rows.Err() +} + +func (r *CapabilityRepository) AvailableCapabilityKinds(ctx context.Context, owner identity.PrincipalID) ([]capability.CapabilityKind, error) { + rows, err := r.pool.Query(ctx, `SELECT DISTINCT i.capability_kind + FROM capability.capability_implementation_heads h + JOIN capability.capability_implementations i ON i.implementation_id=h.implementation_id AND i.revision=h.revision + WHERE i.owner_principal_id=$1 AND i.qualification=$2 AND i.activation=$3 AND i.health=$4 + ORDER BY i.capability_kind`, owner, capability.QualificationValidated, capability.ActivationActive, capability.HealthAvailable) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]capability.CapabilityKind, 0) + for rows.Next() { + var kind capability.CapabilityKind + if err := rows.Scan(&kind); err != nil { + return nil, err + } + result = append(result, kind) + } + return result, rows.Err() +} + +func (r *CapabilityRepository) SaveCapabilityGap(ctx context.Context, value capability.CapabilityGap) error { + strategies := make([]string, 0, len(value.Strategies)) + for _, strategy := range value.Strategies { + strategies = append(strategies, string(strategy.Kind)) + } + _, err := r.pool.Exec(ctx, `INSERT INTO capability.capability_gaps ( + gap_id,requirement_id,capability_kind,capability_id,owner_principal_id,original_intent_id,source_utterance_id, + reason,detail,strategies,recorded_at_unix_millis) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT DO NOTHING`, value.ID, value.RequirementID, + value.CapabilityKind, value.CapabilityID, value.OwnerPrincipalID, value.OriginalIntentID, value.SourceUtteranceID, + value.Reason, value.Detail, strategies, value.RecordedAtUnixMillis) + return err +} + +func (r *CapabilityRepository) LatestCapabilityGap(ctx context.Context, kind capability.CapabilityKind, owner identity.PrincipalID) (capability.CapabilityGap, error) { + var value capability.CapabilityGap + var strategies []string + err := r.pool.QueryRow(ctx, `SELECT gap_id,requirement_id,capability_kind,capability_id,owner_principal_id,original_intent_id,source_utterance_id,reason,detail,strategies,recorded_at_unix_millis + FROM capability.capability_gaps WHERE capability_kind=$1 AND owner_principal_id=$2 ORDER BY recorded_at_unix_millis DESC, gap_id DESC LIMIT 1`, kind, owner). + Scan(&value.ID, &value.RequirementID, &value.CapabilityKind, &value.CapabilityID, &value.OwnerPrincipalID, &value.OriginalIntentID, &value.SourceUtteranceID, &value.Reason, &value.Detail, &strategies, &value.RecordedAtUnixMillis) + if err != nil { + return capability.CapabilityGap{}, err + } + for _, strategy := range strategies { + value.Strategies = append(value.Strategies, capability.AcquisitionStrategyCandidate{Kind: capability.AcquisitionStrategyKind(strategy)}) + } + return value, nil +} + +func (r *CapabilityRepository) SaveCapabilityUsageObservation(ctx context.Context, value capability.CapabilityUsageObservation) error { + _, err := r.pool.Exec(ctx, `INSERT INTO capability.capability_usage_observations ( + observation_id,implementation_id,implementation_revision,capability_id,capability_kind,owner_principal_id, + original_intent_id,source_utterance_id,outcome,outcome_ref,latency_millis,observed_at_unix_millis) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT DO NOTHING`, value.ID, value.ImplementationID, + value.ImplementationRevision, value.CapabilityID, value.CapabilityKind, value.OwnerPrincipalID, value.OriginalIntentID, + value.SourceUtteranceID, value.Outcome, value.OutcomeRef, value.LatencyMillis, value.ObservedAtUnixMillis) + return err +} + func (r *CapabilityRepository) LoadBinding(ctx context.Context, id capability.CapabilityBindingID, revision int64) (capability.Binding, error) { var binding capability.Binding err := r.pool.QueryRow(ctx, `SELECT binding_id,capability_id,owner_principal_id,revision,state,https_origin,delivery_path,receipt_path, diff --git a/adapters/postgres/continuity_repository.go b/adapters/postgres/continuity_repository.go index b3d26b7..961973b 100644 --- a/adapters/postgres/continuity_repository.go +++ b/adapters/postgres/continuity_repository.go @@ -34,12 +34,12 @@ func OpenContinuity(ctx context.Context, config ContinuityConfig) (*ContinuityRe } func (r *ContinuityRepository) Close() { r.pool.Close() } func (r *ContinuityRepository) Save(ctx context.Context, item continuity.ProspectiveItem) error { - _, err := r.pool.Exec(ctx, `INSERT INTO continuity.prospective_items (prospective_item_id, commitment_id, owner_principal_id, scope_id, goal_id, trigger_at_unix_millis, state, revision) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (prospective_item_id) DO UPDATE SET state=EXCLUDED.state, revision=EXCLUDED.revision`, item.ID, item.CommitmentID, item.OwnerPrincipalID, item.ScopeID, item.GoalID, item.TriggerAtUnixMillis, item.State, item.Revision) + _, err := r.pool.Exec(ctx, `INSERT INTO continuity.prospective_items (prospective_item_id, commitment_id, owner_principal_id, scope_id, goal_id, source_intent_id, source_utterance_id, summary, trigger_at_unix_millis, state, revision) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT (prospective_item_id) DO UPDATE SET state=EXCLUDED.state, revision=EXCLUDED.revision`, item.ID, item.CommitmentID, item.OwnerPrincipalID, item.ScopeID, item.GoalID, item.SourceIntentID, item.SourceUtteranceID, item.Summary, item.TriggerAtUnixMillis, item.State, item.Revision) return err } func (r *ContinuityRepository) Load(ctx context.Context, id continuity.ProspectiveItemID) (continuity.ProspectiveItem, error) { var item continuity.ProspectiveItem - err := r.pool.QueryRow(ctx, `SELECT prospective_item_id, commitment_id, owner_principal_id, scope_id, goal_id, trigger_at_unix_millis, state, revision FROM continuity.prospective_items WHERE prospective_item_id=$1`, id).Scan(&item.ID, &item.CommitmentID, &item.OwnerPrincipalID, &item.ScopeID, &item.GoalID, &item.TriggerAtUnixMillis, &item.State, &item.Revision) + err := r.pool.QueryRow(ctx, `SELECT prospective_item_id, commitment_id, owner_principal_id, scope_id, goal_id, source_intent_id, source_utterance_id, summary, trigger_at_unix_millis, state, revision FROM continuity.prospective_items WHERE prospective_item_id=$1`, id).Scan(&item.ID, &item.CommitmentID, &item.OwnerPrincipalID, &item.ScopeID, &item.GoalID, &item.SourceIntentID, &item.SourceUtteranceID, &item.Summary, &item.TriggerAtUnixMillis, &item.State, &item.Revision) if err != nil { return continuity.ProspectiveItem{}, fmt.Errorf("load prospective item: %w", err) } @@ -47,7 +47,7 @@ func (r *ContinuityRepository) Load(ctx context.Context, id continuity.Prospecti } func (r *ContinuityRepository) ByOwner(ctx context.Context, owner identity.PrincipalID, limit int) ([]continuity.ProspectiveItem, error) { - rows, err := r.pool.Query(ctx, `SELECT prospective_item_id, commitment_id, owner_principal_id, scope_id, goal_id, trigger_at_unix_millis, state, revision FROM continuity.prospective_items WHERE owner_principal_id=$1 ORDER BY trigger_at_unix_millis, prospective_item_id LIMIT $2`, owner, limit) + rows, err := r.pool.Query(ctx, `SELECT prospective_item_id, commitment_id, owner_principal_id, scope_id, goal_id, source_intent_id, source_utterance_id, summary, trigger_at_unix_millis, state, revision FROM continuity.prospective_items WHERE owner_principal_id=$1 ORDER BY trigger_at_unix_millis, prospective_item_id LIMIT $2`, owner, limit) if err != nil { return nil, fmt.Errorf("list prospective items by owner: %w", err) } @@ -55,7 +55,7 @@ func (r *ContinuityRepository) ByOwner(ctx context.Context, owner identity.Princ items := make([]continuity.ProspectiveItem, 0) for rows.Next() { var item continuity.ProspectiveItem - if err := rows.Scan(&item.ID, &item.CommitmentID, &item.OwnerPrincipalID, &item.ScopeID, &item.GoalID, &item.TriggerAtUnixMillis, &item.State, &item.Revision); err != nil { + if err := rows.Scan(&item.ID, &item.CommitmentID, &item.OwnerPrincipalID, &item.ScopeID, &item.GoalID, &item.SourceIntentID, &item.SourceUtteranceID, &item.Summary, &item.TriggerAtUnixMillis, &item.State, &item.Revision); err != nil { return nil, fmt.Errorf("scan prospective item: %w", err) } items = append(items, item) @@ -95,10 +95,10 @@ func (r *ContinuityRepository) SatisfyDelivered(ctx context.Context, command con err = tx.QueryRow(ctx, `UPDATE continuity.prospective_items SET state=$1, revision=revision+1 WHERE prospective_item_id=$2 AND owner_principal_id=$3 AND state=$4 AND revision=$5 - RETURNING prospective_item_id,commitment_id,owner_principal_id,scope_id,goal_id,trigger_at_unix_millis,state,revision`, + RETURNING prospective_item_id,commitment_id,owner_principal_id,scope_id,goal_id,source_intent_id,source_utterance_id,summary,trigger_at_unix_millis,state,revision`, continuity.ProspectiveSatisfied, command.Subject.ProspectiveItemID, command.OwnerPrincipalID, continuity.ProspectiveDelivered, command.Subject.ExpectedRevision).Scan( - &item.ID, &item.CommitmentID, &item.OwnerPrincipalID, &item.ScopeID, &item.GoalID, + &item.ID, &item.CommitmentID, &item.OwnerPrincipalID, &item.ScopeID, &item.GoalID, &item.SourceIntentID, &item.SourceUtteranceID, &item.Summary, &item.TriggerAtUnixMillis, &item.State, &item.Revision) if errors.Is(err, pgx.ErrNoRows) { return continuity.ActionReceipt{}, diagnoseProspectiveConflict(ctx, tx, command) diff --git a/architecture/adrs/ADR-0013-capability-resolution-selection-gap-foundation.md b/architecture/adrs/ADR-0013-capability-resolution-selection-gap-foundation.md new file mode 100644 index 0000000..44eee87 --- /dev/null +++ b/architecture/adrs/ADR-0013-capability-resolution-selection-gap-foundation.md @@ -0,0 +1,33 @@ +# ADR-0013 — Capability Resolution, Selection and Gap Handling Foundation + +Status: ACCEPTED + +Date: 2026-08-17 + +## Context + +DEV-000063 introduced Work-owned `CapabilityRequirement` without selecting or executing concrete implementations. The system now needs a provider-neutral way to answer which real capabilities can satisfy a requirement, whether they are operationally usable, whether policy permits them, and what typed gap remains when no implementation is eligible. + +The Core Semantic Model already assigns Capability registry/bindings ownership to the Capability module. Cognitive Registry is a specialized capability registry for model/provider/model-binding semantics. This ADR must not create a parallel cognitive source of truth. + +## Decision + +1. Keep `capability` as the canonical owner for capability descriptors, implementation registrations, implementation health/availability, resolution results, typed gaps, acquisition strategy candidates and generic capability usage observations. +2. Represent a capability separately from an implementation: + - `Capability` / `CapabilityRef`: what the system can obtain or do. + - `CapabilityImplementation`: a concrete adapter, provider, runtime, agent or internal function that may satisfy that capability. +3. Capability identity is typed but extensible. Known `CapabilityKind` constants are allowed for current semantics, but the registry stores string capability kinds and stable capability IDs without imposing a closed universe. +4. Resolver V1 returns `AVAILABLE`, `PARTIALLY_AVAILABLE`, `UNAVAILABLE` or `UNKNOWN` with eligible implementations and typed exclusion/gap reasons. +5. Selection V1 is deterministic and evidence-weighted. It must rank eligible implementations by typed evidence and stable IDs, not by hardcoded provider/adapter names. +6. CredentialRef is allowed as implementation metadata, but credential existence never means capability availability or Authority. Secret values remain outside canonical state and are resolved only by trusted runtimes. +7. Cognitive systems remain integrated through the existing Cognitive Registry and Router. General capability resolution may express the conceptual relation that cognition is a capability, but cognitive invocation continues through the existing gateway/router. +8. Reminder scheduling is the first concrete production capability for this foundation. It creates Continuity-owned `ProspectiveItem` state through the Continuity runtime boundary. It does not create durable Work for simple reminders. +9. Unsupported email sending creates a Capability-owned `CapabilityGap` correlated to the original human intent. It must not claim execution and must not become an automatic Codex/self-extension task. + +## Consequences + +- `capability` gains generic implementation, resolution, gap and usage-observation contracts/tables. +- `continuity` gains a narrow authenticated creator reminder-registration endpoint that owns creation of `ProspectiveItem` rows. +- `/chat` can schedule a simple reminder through capability resolution and can persist a typed gap for unavailable capabilities. +- No cognitive binding is promoted to `ACTIVE`. +- No new canonical owner, broad plugin marketplace, autonomous adapter installation or self-modifying loop is introduced. diff --git a/architecture/architecture-registry.json b/architecture/architecture-registry.json index db65de6..5efa0aa 100644 --- a/architecture/architecture-registry.json +++ b/architecture/architecture-registry.json @@ -114,7 +114,13 @@ "owns": [ "Capability", "CapabilityBinding", - "CapabilityHealth" + "CapabilityHealth", + "CapabilityImplementation", + "CapabilityResolution", + "CapabilitySelection", + "CapabilityGap", + "AcquisitionStrategyCandidate", + "CapabilityUsageObservation" ], "database_schema": "capability", "boundary_class": "remote_capable", diff --git a/architecture/architecture-registry.yaml b/architecture/architecture-registry.yaml index f23f4ab..04e4cd1 100644 --- a/architecture/architecture-registry.yaml +++ b/architecture/architecture-registry.yaml @@ -48,7 +48,7 @@ modules: capability: id: mod.capability - owns: [Capability, CapabilityBinding, CapabilityHealth] + owns: [Capability, CapabilityBinding, CapabilityHealth, CapabilityImplementation, CapabilityResolution, CapabilitySelection, CapabilityGap, AcquisitionStrategyCandidate, CapabilityUsageObservation] database_schema: capability boundary_class: remote_capable may_depend_on: [contracts.identity, contracts.authority, contracts.artifact] diff --git a/cmd/continuity-runtime/main.go b/cmd/continuity-runtime/main.go index 6285341..db520fa 100644 --- a/cmd/continuity-runtime/main.go +++ b/cmd/continuity-runtime/main.go @@ -47,6 +47,7 @@ func main() { } w.WriteHeader(http.StatusCreated) }) + registerCreatorReminderRoute(mux, identityClient, repository) mux.HandleFunc("GET /v1/prospective-items/{id}", func(w http.ResponseWriter, r *http.Request) { item, err := repository.Load(r.Context(), continuity.ProspectiveItemID(r.PathValue("id"))) if err != nil { diff --git a/cmd/continuity-runtime/reminder.go b/cmd/continuity-runtime/reminder.go new file mode 100644 index 0000000..1ca91d9 --- /dev/null +++ b/cmd/continuity-runtime/reminder.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "git.cin.su/m/Assistant/contracts/continuity" + continuitydomain "git.cin.su/m/Assistant/modules/continuity/domain" + "git.cin.su/m/Assistant/platform/ids" +) + +type reminderRepository interface { + Save(context.Context, continuity.ProspectiveItem) error +} + +func registerCreatorReminderRoute(mux *http.ServeMux, identityClient creatorIdentityClient, repository reminderRepository) { + mux.HandleFunc("POST /v1/creator/reminders", 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.ScheduleReminderRequest + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + http.Error(w, "invalid reminder", http.StatusBadRequest) + return + } + request.OwnerPrincipalID = session.PrincipalID + id, err := ids.New() + if err != nil { + http.Error(w, "reminder unavailable", http.StatusServiceUnavailable) + return + } + item, err := continuitydomain.BuildReminder(request, continuity.ProspectiveItemID(id)) + if err != nil { + http.Error(w, "invalid reminder", http.StatusBadRequest) + return + } + if err := repository.Save(r.Context(), item); err != nil { + http.Error(w, "save reminder", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(continuity.ScheduleReminderResult{Item: item}) + }) +} diff --git a/cmd/continuity-runtime/reminder_test.go b/cmd/continuity-runtime/reminder_test.go new file mode 100644 index 0000000..6412e21 --- /dev/null +++ b/cmd/continuity-runtime/reminder_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.cin.su/m/Assistant/contracts/continuity" + "git.cin.su/m/Assistant/contracts/identity" +) + +func TestCreatorReminderEndpointBuildsOwnedProspectiveItem(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 := &fakeReminderRepository{} + mux := http.NewServeMux() + registerCreatorReminderRoute(mux, identityClient, repository) + request := httptest.NewRequest(http.MethodPost, "/v1/creator/reminders", strings.NewReader(`{"ScopeID":"scope-1","SourceIntentID":"intent-1","SourceUtteranceID":"utterance-1","Summary":"позвонить врачу","TriggerAtUnixMillis":2000,"RequestedAtUnixMillis":1000}`)) + request.Header.Set("Authorization", "Bearer ok") + recorder := httptest.NewRecorder() + mux.ServeHTTP(recorder, request) + if recorder.Code != http.StatusCreated || repository.saved.OwnerPrincipalID != "principal-1" || repository.saved.Summary != "позвонить врачу" || repository.saved.State != continuity.ProspectiveArmed { + t.Fatalf("status=%d saved=%#v body=%s", recorder.Code, repository.saved, recorder.Body.String()) + } +} + +type fakeReminderRepository struct { + saved continuity.ProspectiveItem +} + +func (f *fakeReminderRepository) Save(_ context.Context, item continuity.ProspectiveItem) error { + f.saved = item + return nil +} diff --git a/cmd/core-runtime/capability_chat.go b/cmd/core-runtime/capability_chat.go new file mode 100644 index 0000000..8e19776 --- /dev/null +++ b/cmd/core-runtime/capability_chat.go @@ -0,0 +1,194 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "git.cin.su/m/Assistant/adapters/postgres" + "git.cin.su/m/Assistant/contracts/authority" + "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/contracts/continuity" + "git.cin.su/m/Assistant/contracts/identity" + "git.cin.su/m/Assistant/contracts/interaction" + capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain" + "git.cin.su/m/Assistant/platform/ids" +) + +type continuityReminderClient 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 == "" { + return continuityReminderClient{}, fmt.Errorf("invalid Continuity Runtime URL") + } + if client == nil { + client = http.DefaultClient + } + return continuityReminderClient{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" + payload, err := json.Marshal(request) + if err != nil { + return continuity.ScheduleReminderResult{}, err + } + upstream, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload)) + if err != nil { + return continuity.ScheduleReminderResult{}, err + } + upstream.Header.Set("Content-Type", "application/json") + upstream.Header.Set("Authorization", authorization) + response, err := c.client.Do(upstream) + if err != nil { + return continuity.ScheduleReminderResult{}, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusCreated { + return continuity.ScheduleReminderResult{}, fmt.Errorf("continuity reminder status %d", response.StatusCode) + } + var result continuity.ScheduleReminderResult + if err := json.NewDecoder(response.Body).Decode(&result); err != nil { + return continuity.ScheduleReminderResult{}, err + } + if result.Item.ID == "" || result.Item.State != continuity.ProspectiveArmed { + return continuity.ScheduleReminderResult{}, fmt.Errorf("invalid reminder result") + } + 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 { + return capability.CapabilityResolution{}, err + } + requirement := capability.CapabilityResolutionRequirement{ + RequirementID: capability.RequirementRef(requirementID), + CapabilityKind: kind, + OwnerPrincipalID: session.PrincipalID, + ActorID: session.ActorID, + OriginalIntentID: capability.OriginalIntentRef(intent.ID), + SourceUtteranceID: capability.SourceUtteranceRef(utterance.ID), + RequiredConstraints: []capability.DataConstraint{capability.DataConstraintLocalOnly}, + AuthorityContextRef: capability.AuthorityPolicyCreatorDirectiveV1, + ObservedAtUnixMillis: now.UnixMilli(), + } + candidates, err := repository.LoadCapabilityImplementations(ctx, kind, session.PrincipalID) + if err != nil { + return capability.CapabilityResolution{}, err + } + resolution, err := capabilitydomain.ResolveCapability(requirement, candidates) + if errors.Is(err, capabilitydomain.ErrNoCapabilityImplementation) && resolution.Gap != nil { + if saveErr := repository.SaveCapabilityGap(ctx, *resolution.Gap); saveErr != nil { + return capability.CapabilityResolution{}, saveErr + } + return resolution, err + } + return resolution, err +} + +func observeCapabilityUsage(ctx context.Context, repository *postgres.CapabilityRepository, selected capability.ResolvedCapabilityImplementation, requirement capability.CapabilityResolutionRequirement, outcome capability.UsageOutcomeClass, outcomeRef string, started time.Time, now time.Time) { + id, err := ids.New() + if err != nil { + return + } + latency := now.Sub(started).Milliseconds() + observation := capability.CapabilityUsageObservation{ + ID: capability.CapabilityUsageObservationID(id), + ImplementationID: selected.Implementation.ID, + ImplementationRevision: selected.Implementation.Revision, + CapabilityID: selected.Implementation.CapabilityID, + CapabilityKind: selected.Implementation.CapabilityKind, + OwnerPrincipalID: selected.Implementation.OwnerPrincipalID, + OriginalIntentID: requirement.OriginalIntentID, + SourceUtteranceID: requirement.SourceUtteranceID, + Outcome: outcome, + OutcomeRef: outcomeRef, + LatencyMillis: &latency, + ObservedAtUnixMillis: now.UnixMilli(), + } + _ = repository.SaveCapabilityUsageObservation(ctx, observation) +} + +func buildReminderRequest(message string, session identity.CreatorSession, intent interaction.OriginalHumanIntent, utterance interaction.HumanUtterance, now time.Time) (continuity.ScheduleReminderRequest, error) { + trigger, err := parseReminderTime(message, now) + if err != nil { + return continuity.ScheduleReminderRequest{}, err + } + summary := reminderSummary(message) + if summary == "" { + summary = strings.TrimSpace(message) + } + return continuity.ScheduleReminderRequest{ + OwnerPrincipalID: session.PrincipalID, + ScopeID: authority.ScopeID("domovoy-global-scope"), + SourceIntentID: string(intent.ID), + SourceUtteranceID: string(utterance.ID), + Summary: summary, + TriggerAtUnixMillis: trigger.UnixMilli(), + RequestedAtUnixMillis: now.UnixMilli(), + }, nil +} + +func parseReminderTime(message string, now time.Time) (time.Time, error) { + lower := strings.ToLower(message) + day := now + if strings.Contains(lower, "послезавтра") { + day = now.AddDate(0, 0, 2) + } else if strings.Contains(lower, "завтра") { + day = now.AddDate(0, 0, 1) + } + hour, minute := 9, 0 + re := regexp.MustCompile(`(?:в|на)\s+([0-2]?\d)(?::([0-5]\d))?`) + if match := re.FindStringSubmatch(lower); len(match) > 0 { + parsedHour, err := strconv.Atoi(match[1]) + if err != nil || parsedHour > 23 { + return time.Time{}, fmt.Errorf("invalid reminder hour") + } + hour = parsedHour + if match[2] != "" { + parsedMinute, err := strconv.Atoi(match[2]) + if err != nil { + return time.Time{}, fmt.Errorf("invalid reminder minute") + } + minute = parsedMinute + } + } + trigger := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, 0, 0, now.Location()) + if !trigger.After(now) { + trigger = trigger.AddDate(0, 0, 1) + } + return trigger, nil +} + +func reminderSummary(message string) string { + clean := strings.TrimSpace(message) + lower := strings.ToLower(clean) + for _, prefix := range []string{"напомни мне", "напомни"} { + if strings.HasPrefix(lower, prefix) { + clean = strings.TrimSpace(clean[len(prefix):]) + lower = strings.ToLower(clean) + break + } + } + for _, word := range []string{"послезавтра", "завтра", "сегодня"} { + clean = strings.TrimSpace(strings.ReplaceAll(strings.ToLower(clean), word, "")) + } + re := regexp.MustCompile(`(?i)(в|на)\s+[0-2]?\d(?::[0-5]\d)?`) + clean = strings.TrimSpace(re.ReplaceAllString(clean, "")) + clean = strings.Trim(clean, " .,!?:;") + return clean +} diff --git a/cmd/core-runtime/capability_chat_test.go b/cmd/core-runtime/capability_chat_test.go new file mode 100644 index 0000000..f64b866 --- /dev/null +++ b/cmd/core-runtime/capability_chat_test.go @@ -0,0 +1,59 @@ +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" + "git.cin.su/m/Assistant/contracts/interaction" +) + +func TestBuildReminderRequestParsesTomorrowAtTen(t *testing.T) { + now := time.Date(2026, 8, 17, 12, 0, 0, 0, time.FixedZone("MSK", 3*60*60)) + request, err := buildReminderRequest("Напомни завтра в 10 позвонить врачу.", identity.CreatorSession{PrincipalID: "principal-1"}, interaction.OriginalHumanIntent{ID: "intent-1"}, interaction.HumanUtterance{ID: "utterance-1"}, now) + if err != nil { + t.Fatal(err) + } + trigger := time.UnixMilli(request.TriggerAtUnixMillis).In(now.Location()) + if trigger.Year() != 2026 || trigger.Month() != 8 || trigger.Day() != 18 || trigger.Hour() != 10 || trigger.Minute() != 0 { + t.Fatalf("unexpected trigger: %s", trigger) + } + if request.Summary != "позвонить врачу" || request.SourceIntentID != "intent-1" || request.SourceUtteranceID != "utterance-1" { + t.Fatalf("unexpected request: %#v", request) + } +} + +func TestContinuityReminderClientDoesNotAcceptFalseSuccess(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/creator/reminders" || r.Header.Get("Authorization") != "Bearer session" { + t.Fatal("unexpected reminder request") + } + var request continuity.ScheduleReminderRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.Summary != "позвонить врачу" { + t.Fatalf("bad payload: %#v err=%v", request, err) + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(continuity.ScheduleReminderResult{Item: continuity.ProspectiveItem{ID: "reminder-1", State: continuity.ProspectiveArmed, Summary: request.Summary}}) + })) + defer upstream.Close() + client, err := newContinuityReminderClient(upstream.URL, upstream.Client()) + if err != nil { + t.Fatal(err) + } + result, err := client.Schedule(context.Background(), "Bearer session", continuity.ScheduleReminderRequest{Summary: "позвонить врачу"}) + if err != nil || result.Item.ID != "reminder-1" { + t.Fatalf("result=%#v err=%v", result, err) + } +} + +func TestReminderSummaryRemovesSchedulingWords(t *testing.T) { + if summary := reminderSummary("Напомни завтра в 10 позвонить врачу."); summary != "позвонить врачу" || strings.Contains(summary, "10") { + t.Fatalf("summary = %q", summary) + } +} diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index 47e6ab0..569bd96 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -8,6 +8,7 @@ import ( "git.cin.su/m/Assistant/adapters/postgres" "git.cin.su/m/Assistant/contracts/authority" "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/contracts/continuity" "git.cin.su/m/Assistant/contracts/epistemics" "git.cin.su/m/Assistant/contracts/identity" "git.cin.su/m/Assistant/contracts/interaction" @@ -85,6 +86,10 @@ func main() { log.Fatal(err) } defer interactionRepository.Close() + reminderClient, err := newContinuityReminderClient(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()) @@ -268,10 +273,63 @@ func main() { ReceivedAtUnixMillis: utterance.ReceivedAtUnixMillis, } helpPath := workdomain.DetermineHelpPath(planningInput) + if isCapabilityQuestion(utterance.Content) { + kinds, err := capabilityRepository.AvailableCapabilityKinds(r.Context(), session.PrincipalID) + if err == nil && len(kinds) > 0 { + planningReply = "Сейчас у меня подключены реальные capabilities: " + humanCapabilityList(kinds) + ". Для рассуждений я использую отдельный Cognitive Gateway/Router, если локальный CANARY доступен. Возможности вроде отправки почты пока будут фиксироваться как capability gap, а не как выполненное действие." + } else { + planningReply = "Я умею записывать диалог и намерения, работать с текущим контекстом, строить планы и обращаться к Cognitive Gateway при доступности локального CANARY. Реальные action-capabilities подключаются через registry; если capability нет, я фиксирую gap." + } + needsCognition = false + } if helpPath.Reply != "" { planningReply = helpPath.Reply needsCognition = false } + var capabilityStatus capability.CapabilityResolutionStatus + var capabilityKind capability.CapabilityKind + var capabilityGapID capability.CapabilityGapID + var reminder *continuity.ProspectiveItem + if helpPath.Kind == work.HelpPathDirectCapability && len(helpPath.SuggestedCapabilityKinds) > 0 { + capabilityKind = helpPath.SuggestedCapabilityKinds[0] + started := time.Now() + resolution, resolveErr := resolveChatCapability(r.Context(), capabilityRepository, capabilityKind, session, intent, utterance, now) + capabilityStatus = resolution.Status + if resolveErr == nil && len(resolution.Eligible) > 0 { + reminderRequest, reminderErr := buildReminderRequest(utterance.Content, session, intent, utterance, now) + if reminderErr == nil { + result, scheduleErr := reminderClient.Schedule(r.Context(), r.Header.Get("Authorization"), reminderRequest) + finished := time.Now() + if scheduleErr == nil { + reminder = &result.Item + planningReply = fmt.Sprintf("Готово. Я создал напоминание: %s.", result.Item.Summary) + observeCapabilityUsage(r.Context(), capabilityRepository, resolution.Eligible[0], resolution.Requirement, capability.UsageSucceeded, string(result.Item.ID), started, finished) + } else { + planningReply = "Scheduling capability сейчас недоступна: напоминание не создано. Я не буду притворяться, что действие выполнено." + observeCapabilityUsage(r.Context(), capabilityRepository, resolution.Eligible[0], resolution.Requirement, capability.UsageFailed, "continuity-reminder-unavailable", started, finished) + } + } else { + planningReply = "Мне нужно уточнить время напоминания. Например: «Напомни завтра в 10 позвонить врачу»." + capabilityStatus = capability.ResolutionPartiallyAvailable + } + } else if resolution.Gap != nil { + capabilityGapID = resolution.Gap.ID + planningReply = "Сейчас scheduling capability недоступна, поэтому напоминание не создано. Я зафиксировал capability gap." + } + needsCognition = false + } + if helpPath.Kind == work.HelpPathCapabilityGap && len(helpPath.SuggestedCapabilityKinds) > 0 { + capabilityKind = helpPath.SuggestedCapabilityKinds[0] + resolution, resolveErr := resolveChatCapability(r.Context(), capabilityRepository, capabilityKind, session, intent, utterance, now) + capabilityStatus = resolution.Status + if resolveErr == nil { + planningReply = "Capability найдена, но в этом шаге она не исполняется без отдельного разрешённого action path." + } else if resolution.Gap != nil { + capabilityGapID = resolution.Gap.ID + planningReply = "Сейчас у меня ещё не подключена возможность отправлять почту. Я зафиксировал capability gap, без ложного выполнения." + } + needsCognition = false + } if helpPath.NeedsDurableWork { planning, err := workdomain.BuildPlanningResult(planningInput, now) if err != nil { @@ -289,6 +347,7 @@ func main() { response := interaction.ChatMessageResponse{ ConversationID: conversation.ID, UtteranceID: utterance.ID, + OriginalIntentID: intent.ID, SpeechAct: semantic.Interpretation.SpeechAct, InterpretationState: utterance.InterpretationState, NeedsCognition: needsCognition, @@ -298,6 +357,10 @@ func main() { Clarification: semantic.Interpretation.Clarification, WorkPlanned: workPlanned, PlanningReply: planningReply, + CapabilityStatus: capabilityStatus, + CapabilityKind: capabilityKind, + CapabilityGapID: capabilityGapID, + Reminder: reminder, } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") @@ -689,3 +752,30 @@ func readCredentialRefSecret(name, refName string) (string, error) { Path: fileName, }) } + +func envOr(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} + +func isCapabilityQuestion(message string) bool { + lower := strings.ToLower(message) + return strings.Contains(lower, "что ты умеешь") || strings.Contains(lower, "что умеешь") || strings.Contains(lower, "какие возможности") +} + +func humanCapabilityList(kinds []capability.CapabilityKind) string { + labels := make([]string, 0, len(kinds)) + for _, kind := range kinds { + switch kind { + case capability.CapabilityKindScheduleReminder: + labels = append(labels, "создавать напоминания") + case capability.CapabilityKindCognitiveReasoning: + labels = append(labels, "отвечать через когнитивный gateway") + default: + labels = append(labels, strings.ToLower(strings.ReplaceAll(string(kind), "_", " "))) + } + } + return strings.Join(labels, ", ") +} diff --git a/cmd/creator-web/chat_test.go b/cmd/creator-web/chat_test.go index 591a783..f24140b 100644 --- a/cmd/creator-web/chat_test.go +++ b/cmd/creator-web/chat_test.go @@ -93,3 +93,37 @@ func TestChatMessageUsesCoreAndLocalOnlyCognitiveGateway(t *testing.T) { t.Fatalf("unexpected trusted gateway request: %#v", cognitiveRequest) } } + +func TestChatMessageReturnsHumanCapabilityMetadata(t *testing.T) { + core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/creator/sessions/current": + _ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor-1", PrincipalID: "principal-1"}) + case r.Method == http.MethodPost && r.URL.Path == "/v1/creator/chat/messages": + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(interaction.ChatMessageResponse{ConversationID: "conversation-1", UtteranceID: "utterance-1", SpeechAct: interaction.SpeechActDirective, FallbackText: "fallback", PlanningReply: "Готово. Я создал напоминание: позвонить врачу.", CapabilityStatus: capability.ResolutionAvailable, CapabilityKind: capability.CapabilityKindScheduleReminder}) + default: + t.Fatalf("unexpected core route %s %s", r.Method, r.URL.Path) + } + })) + defer core.Close() + handler, err := newHandler(core.URL, "http://continuity", "http://commit", core.Client(), "http://cognitive") + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/chat/messages", strings.NewReader(`{"message":"Напомни завтра в 10 позвонить врачу."}`)) + request.Header.Set("Authorization", "Bearer session-token") + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + var response browserChatResponse + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatal(err) + } + if response.ReplyText != "Готово. Я создал напоминание: позвонить врачу." || response.CapabilityStatus != string(capability.ResolutionAvailable) || response.CapabilityKind != string(capability.CapabilityKindScheduleReminder) { + t.Fatalf("unexpected browser response: %#v", response) + } +} diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go index 6b5d178..bac078c 100644 --- a/cmd/creator-web/server.go +++ b/cmd/creator-web/server.go @@ -151,6 +151,9 @@ type browserChatResponse struct { ProjectProvenance string `json:"project_provenance"` Clarification string `json:"clarification"` WorkPlanned bool `json:"work_planned"` + CapabilityStatus string `json:"capability_status"` + CapabilityKind string `json:"capability_kind"` + ReminderScheduled bool `json:"reminder_scheduled"` } func (g *gateway) handleAdvisoryPolicy(w http.ResponseWriter, r *http.Request) { @@ -340,7 +343,7 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") - _ = json.NewEncoder(w).Encode(browserChatResponse{ConversationID: string(saved.ConversationID), UtteranceID: string(saved.UtteranceID), SpeechAct: string(saved.SpeechAct), ReplyText: reply, Cognition: cognition, ActiveFocus: saved.ActiveProjectName, ProjectProvenance: string(saved.ProjectProvenance), Clarification: string(saved.Clarification), WorkPlanned: saved.WorkPlanned}) + _ = json.NewEncoder(w).Encode(browserChatResponse{ConversationID: string(saved.ConversationID), UtteranceID: string(saved.UtteranceID), SpeechAct: string(saved.SpeechAct), ReplyText: reply, Cognition: cognition, ActiveFocus: saved.ActiveProjectName, ProjectProvenance: string(saved.ProjectProvenance), Clarification: string(saved.Clarification), WorkPlanned: saved.WorkPlanned, CapabilityStatus: string(saved.CapabilityStatus), CapabilityKind: string(saved.CapabilityKind), ReminderScheduled: saved.Reminder != nil}) } func validAdvisoryResponse(response cognitive.GatewayAdvisoryResponse, request cognitive.GatewayAdvisoryRequest) bool { diff --git a/cmd/creator-web/static/assets/chat.js b/cmd/creator-web/static/assets/chat.js index 3c8ba63..ba27c6c 100644 --- a/cmd/creator-web/static/assets/chat.js +++ b/cmd/creator-web/static/assets/chat.js @@ -81,6 +81,8 @@ chatForm.addEventListener("submit", async (event) => { updateActiveFocus(result.active_focus, result.project_provenance); const semanticMeta = [result.speech_act, `cognition=${result.cognition}`]; if (result.work_planned) semanticMeta.push("work=planned"); + if (result.capability_status) semanticMeta.push(`capability=${result.capability_kind || "unknown"}:${result.capability_status}`); + if (result.reminder_scheduled) semanticMeta.push("reminder=scheduled"); if (result.clarification) semanticMeta.push(`clarification=${result.clarification}`); appendMessage("assistant", result.reply_text || "Сообщение записано.", semanticMeta.join("; ")); } catch { diff --git a/contracts/capability/resolution.go b/contracts/capability/resolution.go new file mode 100644 index 0000000..6efbff4 --- /dev/null +++ b/contracts/capability/resolution.go @@ -0,0 +1,162 @@ +package capability + +import ( + "git.cin.su/m/Assistant/contracts/identity" + "git.cin.su/m/Assistant/platform/ids" +) + +type CapabilityImplementationID ids.ID +type CapabilityResolutionID ids.ID +type CapabilityGapID ids.ID +type CapabilityUsageObservationID ids.ID +type RequirementRef string +type OriginalIntentRef string +type SourceUtteranceRef string + +type ImplementationKind string +type ImplementationTargetRef string +type AuthorityPolicyRef string +type DataConstraint string +type CapabilityResolutionStatus string +type GapReason string +type AcquisitionStrategyKind string +type UsageOutcomeClass string + +const ( + ImplementationInternalFunction ImplementationKind = "INTERNAL_FUNCTION" + ImplementationCognitiveBinding ImplementationKind = "COGNITIVE_BINDING" + ImplementationExternalAdapter ImplementationKind = "EXTERNAL_ADAPTER" + ImplementationAgent ImplementationKind = "AGENT" + + AuthorityPolicyCreatorDirectiveV1 AuthorityPolicyRef = "creator-directive-v1" + AuthorityPolicyCognitiveGatewayV1 AuthorityPolicyRef = "cognitive-gateway-v1" + + DataConstraintLocalOnly DataConstraint = "LOCAL_ONLY" + DataConstraintExternalAllowed DataConstraint = "EXTERNAL_ALLOWED" + DataConstraintNoCredentialForward DataConstraint = "NO_CREDENTIAL_FORWARD" + + ResolutionAvailable CapabilityResolutionStatus = "AVAILABLE" + ResolutionPartiallyAvailable CapabilityResolutionStatus = "PARTIALLY_AVAILABLE" + ResolutionUnavailable CapabilityResolutionStatus = "UNAVAILABLE" + ResolutionUnknown CapabilityResolutionStatus = "UNKNOWN" + + GapNoImplementation GapReason = "NO_IMPLEMENTATION" + GapUnhealthy GapReason = "UNHEALTHY" + GapInactive GapReason = "INACTIVE" + GapUnqualified GapReason = "UNQUALIFIED" + GapUnauthorized GapReason = "UNAUTHORIZED" + GapIncompatible GapReason = "INCOMPATIBLE" + GapUnknown GapReason = "UNKNOWN" + + AcquireUseAlternative AcquisitionStrategyKind = "USE_ALTERNATIVE" + AcquireDiscoverExisting AcquisitionStrategyKind = "DISCOVER_EXISTING" + AcquireConnectExisting AcquisitionStrategyKind = "CONNECT_EXISTING" + AcquireConfigureExisting AcquisitionStrategyKind = "CONFIGURE_EXISTING" + AcquireExternal AcquisitionStrategyKind = "ACQUIRE_EXTERNAL" + AcquireBuildNew AcquisitionStrategyKind = "BUILD_NEW" + AcquireRequestUserInput AcquisitionStrategyKind = "REQUEST_USER_INPUT" + AcquireUnresolvableCurrently AcquisitionStrategyKind = "UNRESOLVABLE_CURRENTLY" + + UsageSucceeded UsageOutcomeClass = "SUCCEEDED" + UsageFailed UsageOutcomeClass = "FAILED" + UsageSkipped UsageOutcomeClass = "SKIPPED" + UsageUnknown UsageOutcomeClass = "UNKNOWN" +) + +// CapabilityDescriptor names what Domovoy can obtain or do. CapabilityKind is +// intentionally extensible string semantics, not a closed-world enum. +type CapabilityDescriptor struct { + ID CapabilityID + Kind CapabilityKind + DisplayName string + Description string + Revision int64 + RecordedAtUnixMillis int64 +} + +// CapabilityImplementation is the generic implementation/binding layer. It +// may point at a cognitive binding, internal function, agent, adapter or later +// provider type. CredentialRef is metadata only and never grants authority. +type CapabilityImplementation struct { + ID CapabilityImplementationID + CapabilityID CapabilityID + CapabilityKind CapabilityKind + OwnerPrincipalID identity.PrincipalID + Revision int64 + Kind ImplementationKind + TargetRef ImplementationTargetRef + CredentialRef CredentialRef + Qualification QualificationState + Activation ActivationState + Health HealthState + AuthorityPolicyRef AuthorityPolicyRef + DataConstraints []DataConstraint + CostScoreMillis *int64 + LatencyScoreMillis *int64 + ReliabilityMillis *int64 + ObservedAtUnixMillis int64 +} + +type CapabilityResolutionRequirement struct { + RequirementID RequirementRef + CapabilityKind CapabilityKind + CapabilityID CapabilityID + OwnerPrincipalID identity.PrincipalID + ActorID identity.ActorID + OriginalIntentID OriginalIntentRef + SourceUtteranceID SourceUtteranceRef + RequiredConstraints []DataConstraint + AllowCanary bool + AuthorityContextRef AuthorityPolicyRef + ObservedAtUnixMillis int64 +} + +type ResolvedCapabilityImplementation struct { + Implementation CapabilityImplementation + Selected bool + Rank int +} + +type CapabilityResolution struct { + ID CapabilityResolutionID + Requirement CapabilityResolutionRequirement + Status CapabilityResolutionStatus + Eligible []ResolvedCapabilityImplementation + ExcludedReasons map[CapabilityImplementationID]GapReason + Gap *CapabilityGap + ObservedAtUnixMillis int64 +} + +type CapabilityGap struct { + ID CapabilityGapID + RequirementID RequirementRef + CapabilityKind CapabilityKind + CapabilityID CapabilityID + OwnerPrincipalID identity.PrincipalID + OriginalIntentID OriginalIntentRef + SourceUtteranceID SourceUtteranceRef + Reason GapReason + Detail string + Strategies []AcquisitionStrategyCandidate + RecordedAtUnixMillis int64 +} + +type AcquisitionStrategyCandidate struct { + Kind AcquisitionStrategyKind + Reason string +} + +type CapabilityUsageObservation struct { + ID CapabilityUsageObservationID + ImplementationID CapabilityImplementationID + ImplementationRevision int64 + CapabilityID CapabilityID + CapabilityKind CapabilityKind + OwnerPrincipalID identity.PrincipalID + OriginalIntentID OriginalIntentRef + SourceUtteranceID SourceUtteranceRef + Outcome UsageOutcomeClass + OutcomeRef string + LatencyMillis *int64 + ObservedAtUnixMillis int64 +} diff --git a/contracts/continuity/types.go b/contracts/continuity/types.go index fc8dbfb..ca32991 100644 --- a/contracts/continuity/types.go +++ b/contracts/continuity/types.go @@ -34,11 +34,28 @@ type ProspectiveItem struct { OwnerPrincipalID identity.PrincipalID ScopeID authority.ScopeID GoalID work.GoalID + SourceIntentID string + SourceUtteranceID string + Summary string TriggerAtUnixMillis int64 State ProspectiveState Revision ProspectiveRevision } +type ScheduleReminderRequest struct { + OwnerPrincipalID identity.PrincipalID + ScopeID authority.ScopeID + SourceIntentID string + SourceUtteranceID string + Summary string + TriggerAtUnixMillis int64 + RequestedAtUnixMillis int64 +} + +type ScheduleReminderResult struct { + Item ProspectiveItem +} + // 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/contracts/interaction/types.go b/contracts/interaction/types.go index c10fd72..cd0a331 100644 --- a/contracts/interaction/types.go +++ b/contracts/interaction/types.go @@ -1,6 +1,8 @@ package interaction import ( + "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/contracts/continuity" "git.cin.su/m/Assistant/contracts/identity" "git.cin.su/m/Assistant/contracts/situation" ) @@ -121,6 +123,7 @@ type ChatMessageRequest struct { type ChatMessageResponse struct { ConversationID ConversationID UtteranceID HumanUtteranceID + OriginalIntentID OriginalHumanIntentID SpeechAct SpeechAct InterpretationState InterpretationState NeedsCognition bool @@ -130,4 +133,8 @@ type ChatMessageResponse struct { Clarification ClarificationState WorkPlanned bool PlanningReply string + CapabilityStatus capability.CapabilityResolutionStatus + CapabilityKind capability.CapabilityKind + CapabilityGapID capability.CapabilityGapID + Reminder *continuity.ProspectiveItem } diff --git a/database/bootstrap/001_roles_and_schemas.sql b/database/bootstrap/001_roles_and_schemas.sql index 74fe410..1245048 100644 --- a/database/bootstrap/001_roles_and_schemas.sql +++ b/database/bootstrap/001_roles_and_schemas.sql @@ -407,6 +407,131 @@ CREATE TABLE IF NOT EXISTS capability.cognitive_performance_evidence ( CHECK (latency_micros IS NULL OR latency_micros >= 0), CHECK (memory_bytes IS NULL OR memory_bytes >= 0), CHECK (reliability_millis IS NULL OR reliability_millis >= 0), CHECK (observed_at_unix_millis > 0) ); +CREATE TABLE IF NOT EXISTS capability.capability_descriptors ( + capability_id TEXT PRIMARY KEY, + capability_kind TEXT NOT NULL, + display_name TEXT NOT NULL, + description TEXT NOT NULL, + revision BIGINT NOT NULL, + recorded_at_unix_millis BIGINT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (capability_id <> ''), + CHECK (capability_kind <> ''), + CHECK (display_name <> ''), + CHECK (revision > 0), + CHECK (recorded_at_unix_millis > 0) +); +CREATE TABLE IF NOT EXISTS capability.capability_implementations ( + implementation_id TEXT NOT NULL, + capability_id TEXT NOT NULL, + capability_kind TEXT NOT NULL, + owner_principal_id TEXT NOT NULL, + revision BIGINT NOT NULL, + implementation_kind TEXT NOT NULL, + target_ref TEXT NOT NULL, + credential_ref TEXT NOT NULL DEFAULT '', + qualification TEXT NOT NULL, + activation TEXT NOT NULL, + health TEXT NOT NULL, + authority_policy_ref TEXT NOT NULL, + data_constraints TEXT[] NOT NULL, + cost_score_millis BIGINT, + latency_score_millis BIGINT, + reliability_millis BIGINT, + observed_at_unix_millis BIGINT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (implementation_id, revision), + CHECK (implementation_id <> ''), + CHECK (capability_id <> ''), + CHECK (capability_kind <> ''), + CHECK (owner_principal_id <> ''), + CHECK (revision > 0), + CHECK (implementation_kind IN ('INTERNAL_FUNCTION','COGNITIVE_BINDING','EXTERNAL_ADAPTER','AGENT')), + CHECK (target_ref <> ''), + CHECK (qualification IN ('DISCOVERED','PROBING','EVALUATING','VALIDATED','REJECTED')), + CHECK (activation IN ('INACTIVE','CANARY','ACTIVE','DEPRECATED','RETIRED')), + CHECK (health IN ('UNKNOWN','AVAILABLE','DEGRADED','UNAVAILABLE')), + CHECK (authority_policy_ref <> ''), + CHECK (cardinality(data_constraints) > 0), + CHECK (cost_score_millis IS NULL OR cost_score_millis >= 0), + CHECK (latency_score_millis IS NULL OR latency_score_millis >= 0), + CHECK (reliability_millis IS NULL OR reliability_millis >= 0), + CHECK (observed_at_unix_millis > 0) +); +CREATE TABLE IF NOT EXISTS capability.capability_implementation_heads ( + implementation_id TEXT PRIMARY KEY, + revision BIGINT NOT NULL, + capability_kind TEXT NOT NULL, + owner_principal_id TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (revision > 0) +); +CREATE TABLE IF NOT EXISTS capability.capability_gaps ( + gap_id TEXT PRIMARY KEY, + requirement_id TEXT NOT NULL, + capability_kind TEXT NOT NULL, + capability_id TEXT NOT NULL DEFAULT '', + owner_principal_id TEXT NOT NULL, + original_intent_id TEXT NOT NULL, + source_utterance_id TEXT NOT NULL, + reason TEXT NOT NULL, + detail TEXT NOT NULL, + strategies TEXT[] NOT NULL, + recorded_at_unix_millis BIGINT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (requirement_id <> ''), + CHECK (capability_kind <> ''), + CHECK (owner_principal_id <> ''), + CHECK (original_intent_id <> ''), + CHECK (source_utterance_id <> ''), + CHECK (reason IN ('NO_IMPLEMENTATION','UNHEALTHY','INACTIVE','UNQUALIFIED','UNAUTHORIZED','INCOMPATIBLE','UNKNOWN')), + CHECK (detail <> ''), + CHECK (cardinality(strategies) > 0), + CHECK (recorded_at_unix_millis > 0) +); +CREATE TABLE IF NOT EXISTS capability.capability_usage_observations ( + observation_id TEXT PRIMARY KEY, + implementation_id TEXT NOT NULL, + implementation_revision BIGINT NOT NULL, + capability_id TEXT NOT NULL, + capability_kind TEXT NOT NULL, + owner_principal_id TEXT NOT NULL, + original_intent_id TEXT NOT NULL, + source_utterance_id TEXT NOT NULL, + outcome TEXT NOT NULL, + outcome_ref TEXT NOT NULL DEFAULT '', + latency_millis BIGINT, + observed_at_unix_millis BIGINT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (implementation_id <> ''), + CHECK (implementation_revision > 0), + CHECK (capability_id <> ''), + CHECK (capability_kind <> ''), + CHECK (owner_principal_id <> ''), + CHECK (original_intent_id <> ''), + CHECK (source_utterance_id <> ''), + CHECK (outcome IN ('SUCCEEDED','FAILED','SKIPPED','UNKNOWN')), + CHECK (latency_millis IS NULL OR latency_millis >= 0), + CHECK (observed_at_unix_millis > 0) +); +INSERT INTO capability.capability_descriptors(capability_id, capability_kind, display_name, description, revision, recorded_at_unix_millis) +VALUES + ('schedule-reminder', 'SCHEDULE_REMINDER', 'Reminder scheduling', 'Create a Continuity-owned prospective reminder item from an authenticated human directive.', 1, 1), + ('creator-advisory', 'COGNITIVE_REASONING', 'Creator advisory cognition', 'Reasoning capability resolved through the specialized Cognitive Registry and Gateway.', 1, 1) +ON CONFLICT (capability_id) DO NOTHING; +INSERT INTO capability.capability_implementations( + implementation_id, capability_id, capability_kind, owner_principal_id, revision, implementation_kind, target_ref, + credential_ref, qualification, activation, health, authority_policy_ref, data_constraints, + cost_score_millis, latency_score_millis, reliability_millis, observed_at_unix_millis) +VALUES ( + 'internal-reminder-scheduler-v1', 'schedule-reminder', 'SCHEDULE_REMINDER', 'domovoy-creator-principal', + 1, 'INTERNAL_FUNCTION', 'continuity://prospective-reminder-v1', '', 'VALIDATED', 'ACTIVE', 'AVAILABLE', + 'creator-directive-v1', ARRAY['LOCAL_ONLY','NO_CREDENTIAL_FORWARD'], 0, 10, 950, 1 +) +ON CONFLICT DO NOTHING; +INSERT INTO capability.capability_implementation_heads(implementation_id, revision, capability_kind, owner_principal_id) +VALUES ('internal-reminder-scheduler-v1', 1, 'SCHEDULE_REMINDER', 'domovoy-creator-principal') +ON CONFLICT (implementation_id) DO NOTHING; RESET ROLE; SET ROLE continuity_role; @@ -416,11 +541,15 @@ CREATE TABLE IF NOT EXISTS continuity.prospective_items ( owner_principal_id TEXT NOT NULL, scope_id TEXT NOT NULL, goal_id TEXT NOT NULL, + source_intent_id TEXT NOT NULL DEFAULT '', + source_utterance_id TEXT NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '', trigger_at_unix_millis BIGINT NOT NULL, state TEXT NOT NULL, revision BIGINT NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - CHECK (revision > 0) + CHECK (revision > 0), + CHECK (state <> 'ARMED' OR summary <> '') ); CREATE TABLE IF NOT EXISTS continuity.action_receipts ( action_intent_id TEXT PRIMARY KEY, diff --git a/database/migrations/018_capability_resolution_selection_gap.sql b/database/migrations/018_capability_resolution_selection_gap.sql new file mode 100644 index 0000000..23551c5 --- /dev/null +++ b/database/migrations/018_capability_resolution_selection_gap.sql @@ -0,0 +1,139 @@ +BEGIN; + +SET ROLE capability_role; + +CREATE TABLE IF NOT EXISTS capability.capability_descriptors ( + capability_id TEXT PRIMARY KEY, + capability_kind TEXT NOT NULL, + display_name TEXT NOT NULL, + description TEXT NOT NULL, + revision BIGINT NOT NULL, + recorded_at_unix_millis BIGINT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (capability_id <> ''), + CHECK (capability_kind <> ''), + CHECK (display_name <> ''), + CHECK (revision > 0), + CHECK (recorded_at_unix_millis > 0) +); + +CREATE TABLE IF NOT EXISTS capability.capability_implementations ( + implementation_id TEXT NOT NULL, + capability_id TEXT NOT NULL, + capability_kind TEXT NOT NULL, + owner_principal_id TEXT NOT NULL, + revision BIGINT NOT NULL, + implementation_kind TEXT NOT NULL, + target_ref TEXT NOT NULL, + credential_ref TEXT NOT NULL DEFAULT '', + qualification TEXT NOT NULL, + activation TEXT NOT NULL, + health TEXT NOT NULL, + authority_policy_ref TEXT NOT NULL, + data_constraints TEXT[] NOT NULL, + cost_score_millis BIGINT, + latency_score_millis BIGINT, + reliability_millis BIGINT, + observed_at_unix_millis BIGINT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (implementation_id, revision), + CHECK (implementation_id <> ''), + CHECK (capability_id <> ''), + CHECK (capability_kind <> ''), + CHECK (owner_principal_id <> ''), + CHECK (revision > 0), + CHECK (implementation_kind IN ('INTERNAL_FUNCTION','COGNITIVE_BINDING','EXTERNAL_ADAPTER','AGENT')), + CHECK (target_ref <> ''), + CHECK (qualification IN ('DISCOVERED','PROBING','EVALUATING','VALIDATED','REJECTED')), + CHECK (activation IN ('INACTIVE','CANARY','ACTIVE','DEPRECATED','RETIRED')), + CHECK (health IN ('UNKNOWN','AVAILABLE','DEGRADED','UNAVAILABLE')), + CHECK (authority_policy_ref <> ''), + CHECK (cardinality(data_constraints) > 0), + CHECK (cost_score_millis IS NULL OR cost_score_millis >= 0), + CHECK (latency_score_millis IS NULL OR latency_score_millis >= 0), + CHECK (reliability_millis IS NULL OR reliability_millis >= 0), + CHECK (observed_at_unix_millis > 0) +); + +CREATE TABLE IF NOT EXISTS capability.capability_implementation_heads ( + implementation_id TEXT PRIMARY KEY, + revision BIGINT NOT NULL, + capability_kind TEXT NOT NULL, + owner_principal_id TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (revision > 0) +); + +CREATE TABLE IF NOT EXISTS capability.capability_gaps ( + gap_id TEXT PRIMARY KEY, + requirement_id TEXT NOT NULL, + capability_kind TEXT NOT NULL, + capability_id TEXT NOT NULL DEFAULT '', + owner_principal_id TEXT NOT NULL, + original_intent_id TEXT NOT NULL, + source_utterance_id TEXT NOT NULL, + reason TEXT NOT NULL, + detail TEXT NOT NULL, + strategies TEXT[] NOT NULL, + recorded_at_unix_millis BIGINT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (requirement_id <> ''), + CHECK (capability_kind <> ''), + CHECK (owner_principal_id <> ''), + CHECK (original_intent_id <> ''), + CHECK (source_utterance_id <> ''), + CHECK (reason IN ('NO_IMPLEMENTATION','UNHEALTHY','INACTIVE','UNQUALIFIED','UNAUTHORIZED','INCOMPATIBLE','UNKNOWN')), + CHECK (detail <> ''), + CHECK (cardinality(strategies) > 0), + CHECK (recorded_at_unix_millis > 0) +); + +CREATE TABLE IF NOT EXISTS capability.capability_usage_observations ( + observation_id TEXT PRIMARY KEY, + implementation_id TEXT NOT NULL, + implementation_revision BIGINT NOT NULL, + capability_id TEXT NOT NULL, + capability_kind TEXT NOT NULL, + owner_principal_id TEXT NOT NULL, + original_intent_id TEXT NOT NULL, + source_utterance_id TEXT NOT NULL, + outcome TEXT NOT NULL, + outcome_ref TEXT NOT NULL DEFAULT '', + latency_millis BIGINT, + observed_at_unix_millis BIGINT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (implementation_id <> ''), + CHECK (implementation_revision > 0), + CHECK (capability_id <> ''), + CHECK (capability_kind <> ''), + CHECK (owner_principal_id <> ''), + CHECK (original_intent_id <> ''), + CHECK (source_utterance_id <> ''), + CHECK (outcome IN ('SUCCEEDED','FAILED','SKIPPED','UNKNOWN')), + CHECK (latency_millis IS NULL OR latency_millis >= 0), + CHECK (observed_at_unix_millis > 0) +); + +INSERT INTO capability.capability_descriptors(capability_id, capability_kind, display_name, description, revision, recorded_at_unix_millis) +VALUES + ('schedule-reminder', 'SCHEDULE_REMINDER', 'Reminder scheduling', 'Create a Continuity-owned prospective reminder item from an authenticated human directive.', 1, 1), + ('creator-advisory', 'COGNITIVE_REASONING', 'Creator advisory cognition', 'Reasoning capability resolved through the specialized Cognitive Registry and Gateway.', 1, 1) +ON CONFLICT (capability_id) DO NOTHING; + +INSERT INTO capability.capability_implementations( + implementation_id, capability_id, capability_kind, owner_principal_id, revision, implementation_kind, target_ref, + credential_ref, qualification, activation, health, authority_policy_ref, data_constraints, + cost_score_millis, latency_score_millis, reliability_millis, observed_at_unix_millis) +VALUES ( + 'internal-reminder-scheduler-v1', 'schedule-reminder', 'SCHEDULE_REMINDER', 'domovoy-creator-principal', + 1, 'INTERNAL_FUNCTION', 'continuity://prospective-reminder-v1', '', 'VALIDATED', 'ACTIVE', 'AVAILABLE', + 'creator-directive-v1', ARRAY['LOCAL_ONLY','NO_CREDENTIAL_FORWARD'], 0, 10, 950, 1 +) +ON CONFLICT DO NOTHING; + +INSERT INTO capability.capability_implementation_heads(implementation_id, revision, capability_kind, owner_principal_id) +VALUES ('internal-reminder-scheduler-v1', 1, 'SCHEDULE_REMINDER', 'domovoy-creator-principal') +ON CONFLICT (implementation_id) DO NOTHING; + +RESET ROLE; +COMMIT; diff --git a/database/migrations/019_continuity_reminder_metadata.sql b/database/migrations/019_continuity_reminder_metadata.sql new file mode 100644 index 0000000..45a8add --- /dev/null +++ b/database/migrations/019_continuity_reminder_metadata.sql @@ -0,0 +1,20 @@ +BEGIN; + +SET ROLE continuity_role; + +ALTER TABLE continuity.prospective_items ADD COLUMN IF NOT EXISTS source_intent_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE continuity.prospective_items ADD COLUMN IF NOT EXISTS source_utterance_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE continuity.prospective_items ADD COLUMN IF NOT EXISTS summary TEXT NOT NULL DEFAULT ''; + +DO $$ BEGIN +IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='prospective_items_reminder_metadata_v1' AND conrelid='continuity.prospective_items'::regclass) THEN + ALTER TABLE continuity.prospective_items ADD CONSTRAINT prospective_items_reminder_metadata_v1 CHECK ( + state <> 'ARMED' OR summary <> '' + ) NOT VALID; +END IF; +END $$; + +ALTER TABLE continuity.prospective_items VALIDATE CONSTRAINT prospective_items_reminder_metadata_v1; + +RESET ROLE; +COMMIT; diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index ed6c541..217204a 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000063-intent-work-planning-foundation.json", - "task_id": "DEV-000063", + "task_file": "dev/tasks/DEV-000064-capability-resolution-selection-gap-foundation.json", + "task_id": "DEV-000064", "task_revision": 1 } diff --git a/dev/tasks/DEV-000064-capability-resolution-selection-gap-foundation.json b/dev/tasks/DEV-000064-capability-resolution-selection-gap-foundation.json new file mode 100644 index 0000000..6c1ee33 --- /dev/null +++ b/dev/tasks/DEV-000064-capability-resolution-selection-gap-foundation.json @@ -0,0 +1,161 @@ +{ + "task_id": "DEV-000064", + "task_revision": 1, + "architecture_revision": "ADR-0013", + "base_repo_revision": "fe531ab56f43bb37a5786e38b094781d3eccb775", + "risk_tier": "E", + "intent": "Add provider-neutral capability implementation resolution, selection and typed gap handling foundation, with reminder scheduling as the first production end-to-end capability.", + "requirement_ids": [ + "ADR-0013", + "INV-AUTH-001", + "INV-EPI-001", + "INV-OWN-001", + "Capability availability is not Authority", + "Cognitive Registry remains the specialized cognitive capability registry" + ], + "allowed_change_scope": { + "modules": [ + "capability", + "continuity", + "interaction", + "work" + ], + "paths": [ + "architecture/adrs/ADR-0013-capability-resolution-selection-gap-foundation.md", + "architecture/architecture-registry.yaml", + "architecture/architecture-registry.json", + "contracts/capability/**", + "contracts/continuity/**", + "contracts/interaction/**", + "contracts/work/**", + "modules/capability/**", + "modules/continuity/**", + "modules/work/**", + "adapters/postgres/**", + "database/bootstrap/**", + "database/migrations/**", + "cmd/core-runtime/**", + "cmd/continuity-runtime/**", + "cmd/creator-web/**", + "tests/capability_resolution/**", + "tests/owner_acl/**", + "tests/cognitive_advisory/**", + "docs/PROJECT_STATUS.md", + "dev/ACTIVE_TASK.json", + "dev/tasks/DEV-000064-capability-resolution-selection-gap-foundation.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": 3, + "new_canonical_owners": 0, + "new_privileges": 0, + "new_third_party_dependencies": 0, + "new_provider_adapters": 0 + }, + "contracts": { + "consume": [ + "Work CapabilityRequirement", + "Interaction OriginalHumanIntent", + "Continuity ProspectiveItem", + "Cognitive Registry / Router", + "Secret Resolver CredentialRef", + "Authority boundary concepts" + ], + "may_change": [ + "contracts/capability@v1", + "contracts/continuity@v1", + "contracts/interaction@v1" + ] + }, + "semantic_owner": "capability", + "canonical_state": [ + "Capability", + "CapabilityImplementation", + "CapabilityResolution", + "CapabilitySelection", + "CapabilityGap", + "AcquisitionStrategyCandidate", + "CapabilityUsageObservation" + ], + "state_transitions": [ + "implementation qualification DISCOVERED/PROBING/EVALUATING/VALIDATED/REJECTED", + "implementation activation INACTIVE/CANARY/ACTIVE/DEPRECATED/RETIRED", + "implementation health UNKNOWN/AVAILABLE/DEGRADED/UNAVAILABLE", + "resolution AVAILABLE/PARTIALLY_AVAILABLE/UNAVAILABLE/UNKNOWN", + "gap records reasons without executing or granting Authority" + ], + "side_effects_and_privileges": [ + "Capability registry writes are owner-local", + "Reminder scheduling creates Continuity-owned ProspectiveItem through Continuity runtime boundary", + "No external mutation", + "No Commit/Executor invocation for reminder registration", + "No ACTIVE cognitive binding promotion", + "No OpenAI activation", + "CredentialRef is metadata and does not grant Authority" + ], + "persistence_and_migrations": [ + "capability owner-local implementation registry", + "capability owner-local gap records", + "capability owner-local generic usage observations", + "continuity owner-local reminder creation endpoint using existing prospective_items", + "no cross-owner FK", + "no cross-owner SQL join dependency" + ], + "failure_retry_restart": [ + "registered implementation can be unavailable without breaking startup", + "resolver fails closed when implementation is missing, unhealthy, inactive, unqualified or unauthorized", + "capability gap is persisted with OriginalHumanIntent correlation", + "reminder creation is persisted as a Continuity ProspectiveItem", + "cognitive router remains authoritative for cognitive invocation" + ], + "assumptions": [ + "DEV-000063 is production baseline", + "Capability owner is canonical Capability registry owner per Core Semantic Model", + "Continuity owner remains canonical ProspectiveItem owner", + "existing CPU CANARY remains not ACTIVE", + "docker-test prohibited" + ], + "acceptance_scenarios": [ + "Reminder resolves to scheduling implementation", + "Reminder is actually created and can be verified", + "Simple question still works without durable Work", + "Unsupported SEND_EMAIL returns persisted CapabilityGap", + "Unavailable implementation is excluded", + "Same capability with two implementations can be represented", + "Selection does not depend on hardcoded provider name", + "CredentialRef does not grant Authority", + "Capability is not Authority", + "Capability gap preserves OriginalHumanIntent correlation", + "One Goal can require multiple capabilities", + "Cognitive binding remains functioning", + "RTX unavailable does not break CPU cognition", + "LLM-free mode passes" + ], + "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 /chat acceptance" + ], + "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 9cfdcda..72d540d 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -1,6 +1,20 @@ # Assistant project status -Audit date: 2026-08-17. Active implementation: DEV-000063 revision 1. +Audit date: 2026-08-17. Active implementation: DEV-000064 revision 1. + +## DEV-000064 checkpoint + +- `DEV_000064_STATUS = IMPLEMENTED_LOCAL` +- `LAST_COMPLETED = ADR-0013, Capability-owned generic implementation registry/resolution/selection/gap contracts, deterministic evidence-weighted resolver V1, capability gap acquisition strategy foundation, generic capability usage observations, Continuity reminder metadata, authenticated creator reminder registration endpoint, /chat reminder scheduling path, /chat SEND_EMAIL gap path, capability-aware "what can you do" response` +- `CAPABILITY_REGISTRY_STATUS = generic CapabilityImplementation + CapabilityGap + CapabilityUsageObservation foundation added under existing capability owner` +- `COGNITIVE_RELATION_STATUS = cognition remains resolved/invoked through existing Cognitive Registry/Gateway; no parallel cognitive router introduced` +- `REMINDER_CAPABILITY_STATUS = internal-reminder-scheduler-v1 seeded as VALIDATED/ACTIVE/AVAILABLE implementation for SCHEDULE_REMINDER` +- `CAPABILITY_GAP_STATUS = unsupported SEND_EMAIL persists CapabilityGap with OriginalHumanIntent/Utterance correlation` +- `AUTHORITY_STATUS = capability and CredentialRef do not grant Authority; reminder registration requires authenticated creator session and creates only Continuity-owned prospective state` +- `ACTIVE_COGNITIVE_BINDINGS = none; CPU CANARY unchanged` +- `OPENAI_REAL_CREDENTIAL = not required / not activated` +- `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` +- `PENDING = production migration/deployment/live acceptance/PR promotion` ## DEV-000063 checkpoint diff --git a/modules/capability/domain/resolution.go b/modules/capability/domain/resolution.go new file mode 100644 index 0000000..e364bb5 --- /dev/null +++ b/modules/capability/domain/resolution.go @@ -0,0 +1,192 @@ +package domain + +import ( + "errors" + "slices" + "sort" + "strings" + + "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/platform/ids" +) + +var ErrInvalidCapabilityImplementation = errors.New("invalid capability implementation") +var ErrInvalidCapabilityRequirement = errors.New("invalid capability requirement") +var ErrNoCapabilityImplementation = errors.New("no eligible capability implementation") + +func ValidateCapabilityImplementation(value capability.CapabilityImplementation) error { + if value.ID == "" || value.CapabilityID == "" || value.CapabilityKind == "" || value.OwnerPrincipalID == "" || + value.Revision < 1 || value.Kind == "" || value.TargetRef == "" || value.AuthorityPolicyRef == "" || + value.ObservedAtUnixMillis < 1 || !validQualification(value.Qualification) || !validActivation(value.Activation) || + !validHealth(value.Health) { + return ErrInvalidCapabilityImplementation + } + switch value.Kind { + case capability.ImplementationInternalFunction, capability.ImplementationCognitiveBinding, capability.ImplementationExternalAdapter, capability.ImplementationAgent: + default: + return ErrInvalidCapabilityImplementation + } + for _, constraint := range value.DataConstraints { + switch constraint { + case capability.DataConstraintLocalOnly, capability.DataConstraintExternalAllowed, capability.DataConstraintNoCredentialForward: + default: + return ErrInvalidCapabilityImplementation + } + } + return nil +} + +func ResolveCapability(requirement capability.CapabilityResolutionRequirement, candidates []capability.CapabilityImplementation) (capability.CapabilityResolution, error) { + if requirement.CapabilityKind == "" || requirement.OwnerPrincipalID == "" || requirement.OriginalIntentID == "" || requirement.SourceUtteranceID == "" || requirement.ObservedAtUnixMillis < 1 { + return capability.CapabilityResolution{}, ErrInvalidCapabilityRequirement + } + resolutionID, err := ids.New() + if err != nil { + return capability.CapabilityResolution{}, err + } + resolution := capability.CapabilityResolution{ + ID: capability.CapabilityResolutionID(resolutionID), + Requirement: requirement, + Status: capability.ResolutionUnavailable, + ExcludedReasons: map[capability.CapabilityImplementationID]capability.GapReason{}, + ObservedAtUnixMillis: requirement.ObservedAtUnixMillis, + } + eligible := make([]capability.CapabilityImplementation, 0, len(candidates)) + anyForCapability := false + dominantReason := capability.GapNoImplementation + for _, candidate := range candidates { + if candidate.CapabilityKind != requirement.CapabilityKind { + continue + } + if requirement.CapabilityID != "" && candidate.CapabilityID != requirement.CapabilityID { + continue + } + anyForCapability = true + reason := exclusionReason(requirement, candidate) + if reason != "" { + resolution.ExcludedReasons[candidate.ID] = reason + if dominantReason == capability.GapNoImplementation { + dominantReason = reason + } + continue + } + eligible = append(eligible, candidate) + } + if len(eligible) == 0 { + if !anyForCapability { + dominantReason = capability.GapNoImplementation + } + gap, err := BuildCapabilityGap(requirement, dominantReason, gapDetail(dominantReason), requirement.ObservedAtUnixMillis) + if err != nil { + return capability.CapabilityResolution{}, err + } + resolution.Gap = &gap + return resolution, ErrNoCapabilityImplementation + } + sort.SliceStable(eligible, func(i, j int) bool { return betterImplementation(eligible[i], eligible[j]) }) + resolution.Status = capability.ResolutionAvailable + resolution.Eligible = make([]capability.ResolvedCapabilityImplementation, 0, len(eligible)) + for index, item := range eligible { + resolution.Eligible = append(resolution.Eligible, capability.ResolvedCapabilityImplementation{Implementation: item, Selected: index == 0, Rank: index + 1}) + } + return resolution, nil +} + +func BuildCapabilityGap(requirement capability.CapabilityResolutionRequirement, reason capability.GapReason, detail string, millis int64) (capability.CapabilityGap, error) { + id, err := ids.New() + if err != nil { + return capability.CapabilityGap{}, err + } + return capability.CapabilityGap{ + ID: capability.CapabilityGapID(id), + RequirementID: requirement.RequirementID, + CapabilityKind: requirement.CapabilityKind, + CapabilityID: requirement.CapabilityID, + OwnerPrincipalID: requirement.OwnerPrincipalID, + OriginalIntentID: requirement.OriginalIntentID, + SourceUtteranceID: requirement.SourceUtteranceID, + Reason: reason, + Detail: detail, + Strategies: acquisitionStrategies(reason), + RecordedAtUnixMillis: millis, + }, nil +} + +func exclusionReason(requirement capability.CapabilityResolutionRequirement, candidate capability.CapabilityImplementation) capability.GapReason { + if ValidateCapabilityImplementation(candidate) != nil { + return capability.GapIncompatible + } + if candidate.OwnerPrincipalID != requirement.OwnerPrincipalID { + return capability.GapUnauthorized + } + if candidate.Qualification != capability.QualificationValidated { + return capability.GapUnqualified + } + if candidate.Activation != capability.ActivationActive && !(requirement.AllowCanary && candidate.Activation == capability.ActivationCanary) { + return capability.GapInactive + } + if candidate.Health != capability.HealthAvailable { + return capability.GapUnhealthy + } + if requirement.AuthorityContextRef != "" && candidate.AuthorityPolicyRef != requirement.AuthorityContextRef { + return capability.GapUnauthorized + } + for _, required := range requirement.RequiredConstraints { + if !slices.Contains(candidate.DataConstraints, required) { + return capability.GapIncompatible + } + } + return "" +} + +func betterImplementation(left, right capability.CapabilityImplementation) bool { + if left.ReliabilityMillis != nil && right.ReliabilityMillis != nil && *left.ReliabilityMillis != *right.ReliabilityMillis { + return *left.ReliabilityMillis > *right.ReliabilityMillis + } + if left.LatencyScoreMillis != nil && right.LatencyScoreMillis != nil && *left.LatencyScoreMillis != *right.LatencyScoreMillis { + return *left.LatencyScoreMillis < *right.LatencyScoreMillis + } + if left.CostScoreMillis != nil && right.CostScoreMillis != nil && *left.CostScoreMillis != *right.CostScoreMillis { + return *left.CostScoreMillis < *right.CostScoreMillis + } + return string(left.ID) < string(right.ID) +} + +func gapDetail(reason capability.GapReason) string { + switch reason { + case capability.GapNoImplementation: + return "no registered implementation satisfies this capability requirement" + case capability.GapUnhealthy: + return "registered implementations are currently unavailable or degraded" + case capability.GapInactive: + return "registered implementations are not active for this use" + case capability.GapUnqualified: + return "registered implementations are not validated" + case capability.GapUnauthorized: + return "registered implementations are not allowed by the current authority or policy context" + case capability.GapIncompatible: + return "registered implementations do not match the required modality, data or environment constraints" + default: + return "capability availability is unknown" + } +} + +func acquisitionStrategies(reason capability.GapReason) []capability.AcquisitionStrategyCandidate { + switch reason { + case capability.GapNoImplementation: + return []capability.AcquisitionStrategyCandidate{ + {Kind: capability.AcquireDiscoverExisting, Reason: "look for an already available adapter or service"}, + {Kind: capability.AcquireConnectExisting, Reason: "connect an existing provider after explicit authority"}, + {Kind: capability.AcquireBuildNew, Reason: "build a new implementation through a future approved coding capability"}, + } + case capability.GapUnhealthy: + return []capability.AcquisitionStrategyCandidate{ + {Kind: capability.AcquireUseAlternative, Reason: "try another eligible implementation"}, + {Kind: capability.AcquireConfigureExisting, Reason: "repair or reconfigure the registered implementation"}, + } + case capability.GapUnauthorized: + return []capability.AcquisitionStrategyCandidate{{Kind: capability.AcquireRequestUserInput, Reason: "request explicit authority or policy decision"}} + default: + return []capability.AcquisitionStrategyCandidate{{Kind: capability.AcquireUnresolvableCurrently, Reason: strings.ToLower(gapDetail(reason))}} + } +} diff --git a/modules/capability/domain/resolution_test.go b/modules/capability/domain/resolution_test.go new file mode 100644 index 0000000..92378e2 --- /dev/null +++ b/modules/capability/domain/resolution_test.go @@ -0,0 +1,98 @@ +package domain + +import ( + "errors" + "strings" + "testing" + + "git.cin.su/m/Assistant/contracts/capability" +) + +func TestReminderResolvesToSchedulingImplementation(t *testing.T) { + requirement := resolutionRequirement(capability.CapabilityKindScheduleReminder) + resolution, err := ResolveCapability(requirement, []capability.CapabilityImplementation{implementation("scheduler-b", capability.CapabilityKindScheduleReminder, 900, 50), implementation("scheduler-a", capability.CapabilityKindScheduleReminder, 950, 75)}) + if err != nil { + t.Fatal(err) + } + if resolution.Status != capability.ResolutionAvailable || len(resolution.Eligible) != 2 || !resolution.Eligible[0].Selected || resolution.Eligible[0].Implementation.ID != "scheduler-a" { + t.Fatalf("unexpected resolution: %#v", resolution) + } +} + +func TestUnavailableImplementationExcluded(t *testing.T) { + candidate := implementation("scheduler", capability.CapabilityKindScheduleReminder, 900, 50) + candidate.Health = capability.HealthUnavailable + resolution, err := ResolveCapability(resolutionRequirement(capability.CapabilityKindScheduleReminder), []capability.CapabilityImplementation{candidate}) + if !errors.Is(err, ErrNoCapabilityImplementation) || resolution.Gap == nil || resolution.Gap.Reason != capability.GapUnhealthy { + t.Fatalf("expected unhealthy gap, got resolution=%#v err=%v", resolution, err) + } +} + +func TestSelectionDoesNotDependOnProviderName(t *testing.T) { + left := implementation("zzz-provider-name", capability.CapabilityKindScheduleReminder, 980, 20) + right := implementation("aaa-provider-name", capability.CapabilityKindScheduleReminder, 900, 10) + resolution, err := ResolveCapability(resolutionRequirement(capability.CapabilityKindScheduleReminder), []capability.CapabilityImplementation{left, right}) + if err != nil { + t.Fatal(err) + } + if resolution.Eligible[0].Implementation.ID != left.ID { + t.Fatalf("selection appears provider-name driven: %#v", resolution.Eligible) + } +} + +func TestCredentialRefDoesNotGrantAuthority(t *testing.T) { + candidate := implementation("mail", capability.CapabilityKindSendEmail, 900, 50) + candidate.CredentialRef = "credential://mail/token" + candidate.AuthorityPolicyRef = "mail-send-approved-v1" + resolution, err := ResolveCapability(resolutionRequirement(capability.CapabilityKindSendEmail), []capability.CapabilityImplementation{candidate}) + if !errors.Is(err, ErrNoCapabilityImplementation) || resolution.Gap == nil || resolution.Gap.Reason != capability.GapUnauthorized { + t.Fatalf("credential ref must not grant authority: resolution=%#v err=%v", resolution, err) + } +} + +func TestCapabilityGapPreservesOriginalIntentCorrelation(t *testing.T) { + requirement := resolutionRequirement(capability.CapabilityKindSendEmail) + resolution, err := ResolveCapability(requirement, nil) + if !errors.Is(err, ErrNoCapabilityImplementation) || resolution.Gap == nil { + t.Fatalf("expected gap, got resolution=%#v err=%v", resolution, err) + } + if resolution.Gap.OriginalIntentID != requirement.OriginalIntentID || resolution.Gap.SourceUtteranceID != requirement.SourceUtteranceID || len(resolution.Gap.Strategies) == 0 { + t.Fatalf("gap lost correlation/strategies: %#v", resolution.Gap) + } + if strings.Contains(strings.ToLower(resolution.Gap.Detail), "codex") { + t.Fatalf("gap must not become codex-specific: %#v", resolution.Gap) + } +} + +func resolutionRequirement(kind capability.CapabilityKind) capability.CapabilityResolutionRequirement { + return capability.CapabilityResolutionRequirement{ + CapabilityKind: kind, + OwnerPrincipalID: "owner-1", + ActorID: "actor-1", + OriginalIntentID: "intent-1", + SourceUtteranceID: "utterance-1", + RequiredConstraints: []capability.DataConstraint{capability.DataConstraintLocalOnly}, + AuthorityContextRef: capability.AuthorityPolicyCreatorDirectiveV1, + ObservedAtUnixMillis: 1000, + } +} + +func implementation(id string, kind capability.CapabilityKind, reliability int64, latency int64) capability.CapabilityImplementation { + return capability.CapabilityImplementation{ + ID: capability.CapabilityImplementationID(id), + CapabilityID: capability.CapabilityID(strings.ToLower(string(kind))), + CapabilityKind: kind, + OwnerPrincipalID: "owner-1", + Revision: 1, + Kind: capability.ImplementationInternalFunction, + TargetRef: capability.ImplementationTargetRef("internal://" + strings.ToLower(string(kind))), + Qualification: capability.QualificationValidated, + Activation: capability.ActivationActive, + Health: capability.HealthAvailable, + AuthorityPolicyRef: capability.AuthorityPolicyCreatorDirectiveV1, + DataConstraints: []capability.DataConstraint{capability.DataConstraintLocalOnly, capability.DataConstraintNoCredentialForward}, + ReliabilityMillis: &reliability, + LatencyScoreMillis: &latency, + ObservedAtUnixMillis: 1000, + } +} diff --git a/modules/continuity/domain/prospective.go b/modules/continuity/domain/prospective.go index b679e77..88fb848 100644 --- a/modules/continuity/domain/prospective.go +++ b/modules/continuity/domain/prospective.go @@ -12,6 +12,7 @@ var ( ErrProspectiveRevision = errors.New("prospective item revision conflict") ErrInvalidTransition = errors.New("invalid prospective item transition") ErrInvalidSatisfaction = errors.New("invalid delivered satisfaction command") + ErrInvalidReminder = errors.New("invalid reminder request") ) type Store struct { @@ -28,6 +29,27 @@ func ValidateSatisfyDelivered(command contract.SatisfyDeliveredCommand) error { return nil } +func BuildReminder(request contract.ScheduleReminderRequest, id contract.ProspectiveItemID) (contract.ProspectiveItem, error) { + if id == "" || request.OwnerPrincipalID == "" || request.ScopeID == "" || strings.TrimSpace(request.SourceIntentID) == "" || + strings.TrimSpace(request.SourceUtteranceID) == "" || strings.TrimSpace(request.Summary) == "" || + request.TriggerAtUnixMillis <= request.RequestedAtUnixMillis || request.RequestedAtUnixMillis < 1 { + return contract.ProspectiveItem{}, ErrInvalidReminder + } + return contract.ProspectiveItem{ + ID: id, + CommitmentID: contract.CommitmentID(""), + OwnerPrincipalID: request.OwnerPrincipalID, + ScopeID: request.ScopeID, + GoalID: "", + SourceIntentID: strings.TrimSpace(request.SourceIntentID), + SourceUtteranceID: strings.TrimSpace(request.SourceUtteranceID), + Summary: strings.TrimSpace(request.Summary), + TriggerAtUnixMillis: request.TriggerAtUnixMillis, + State: contract.ProspectiveArmed, + Revision: 1, + }, nil +} + func (s *Store) SatisfyDelivered(id contract.ProspectiveItemID, revision contract.ProspectiveRevision, evidenceRef string) (contract.ProspectiveItem, error) { item, err := s.current(id, revision) if err != nil { diff --git a/modules/work/domain/planning.go b/modules/work/domain/planning.go index f654aaa..d980745 100644 --- a/modules/work/domain/planning.go +++ b/modules/work/domain/planning.go @@ -78,6 +78,8 @@ func DetermineHelpPath(input work.PlanningInput) work.HelpPath { return work.HelpPath{Kind: work.HelpPathCognition, Reason: "simple help can be answered without durable Goal/Work/Plan"} case containsAny(lower, "напомни", "напомин"): return work.HelpPath{Kind: work.HelpPathDirectCapability, SuggestedCapabilityKinds: []capability.CapabilityKind{capability.CapabilityKindScheduleReminder}, Reason: "reminder is a direct scheduling-capability path, not a coding/project workflow", Reply: "Понял: нужен reminder/scheduler capability. В DEV-000063 я фиксирую это как безопасный путь помощи; само напоминание ещё не зарегистрировано, потому что scheduling adapter не подключён."} + case containsAny(lower, "отправь", "письм", "почт"): + return work.HelpPath{Kind: work.HelpPathCapabilityGap, SuggestedCapabilityKinds: []capability.CapabilityKind{capability.CapabilityKindSendEmail}, CapabilityGap: true, Reason: "email sending needs a communication capability implementation and explicit authority", Reply: "Сейчас у меня ещё не подключена возможность отправлять почту. Я зафиксирую этот capability gap, чтобы позже к нему можно было вернуться."} case containsAny(lower, "не умеешь", "подключи интеграцию", "интеграц", "неизвестн", "нет capability", "создай adapter", "создай адаптер"): return work.HelpPath{Kind: work.HelpPathCapabilityGap, SuggestedCapabilityKinds: []capability.CapabilityKind{}, CapabilityGap: true, Reason: "requested help needs a currently missing capability and may later trigger acquisition/self-extension", Reply: "Похоже, для этой просьбы нужна capability, которой сейчас нет. Это не задача конкретного executor автоматически: в будущем Домовой сможет найти, подключить или создать подходящую capability и вернуться к исходной потребности."} case requiresDurableWork(lower): -- 2.54.0