Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c6a7bebbd | ||
|
|
36774b37ae | ||
|
|
c79de1c757 | ||
|
|
acf012720f | ||
|
|
ccf790d130 | ||
|
|
54c2cc27a3 | ||
|
|
8b9b841645 | ||
|
|
e8e51ffdba | ||
|
|
aa01e8e99b | ||
|
|
1113ced623 | ||
|
|
f3ff430812 | ||
|
|
2ec53e40cf | ||
|
|
18fbc7f105 | ||
|
|
a0dce634f3 | ||
|
|
5d4e064a94 | ||
|
|
6e73318e49 | ||
|
|
1519af3310 | ||
|
|
8fe596264e | ||
|
|
ee51789ee2 | ||
|
|
a083bac65f | ||
|
|
9a84ac942c | ||
|
|
fe531ab56f | ||
|
|
d8f994e07e | ||
|
|
9d50a3ec63 |
@@ -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,
|
||||
|
||||
@@ -5,10 +5,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
continuitydomain "git.cin.su/m/Assistant/modules/continuity/domain"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -19,6 +21,9 @@ var (
|
||||
ErrProspectiveRevisionConflict = errors.New("prospective item revision conflict")
|
||||
ErrActionReceiptNotFound = errors.New("continuity action receipt not found")
|
||||
ErrActionIntentConflict = errors.New("action intent was already used for a different command")
|
||||
ErrAttentionNotFound = errors.New("attention item not found")
|
||||
ErrAttentionStateConflict = errors.New("attention item state conflict")
|
||||
ErrAttentionRevisionConflict = errors.New("attention item revision conflict")
|
||||
)
|
||||
|
||||
type ContinuityConfig struct{ Host, Port, Database, User, Password string }
|
||||
@@ -34,12 +39,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 +52,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 +60,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)
|
||||
@@ -66,6 +71,361 @@ func (r *ContinuityRepository) ByOwner(ctx context.Context, owner identity.Princ
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *ContinuityRepository) DueArmed(ctx context.Context, nowUnixMillis int64, limit int) ([]continuity.ProspectiveItem, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, fmt.Errorf("invalid due reminder 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 state=$1 AND trigger_at_unix_millis <= $2
|
||||
ORDER BY trigger_at_unix_millis, prospective_item_id LIMIT $3`, continuity.ProspectiveArmed, nowUnixMillis, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list due prospective items: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
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.SourceIntentID, &item.SourceUtteranceID, &item.Summary, &item.TriggerAtUnixMillis, &item.State, &item.Revision); err != nil {
|
||||
return nil, fmt.Errorf("scan due prospective item: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (r *ContinuityRepository) ClaimDueReminder(ctx context.Context, id continuity.ProspectiveItemID, revision continuity.ProspectiveRevision, attentionID continuity.AttentionItemID, nowUnixMillis int64) (continuity.ProspectiveItem, continuity.AttentionItem, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return continuity.ProspectiveItem{}, continuity.AttentionItem{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var triggered continuity.ProspectiveItem
|
||||
err = tx.QueryRow(ctx, `UPDATE continuity.prospective_items
|
||||
SET state=$1, revision=revision+1
|
||||
WHERE prospective_item_id=$2 AND state=$3 AND revision=$4 AND trigger_at_unix_millis <= $5
|
||||
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.ProspectiveTriggered, id, continuity.ProspectiveArmed, revision, nowUnixMillis).Scan(
|
||||
&triggered.ID, &triggered.CommitmentID, &triggered.OwnerPrincipalID, &triggered.ScopeID, &triggered.GoalID,
|
||||
&triggered.SourceIntentID, &triggered.SourceUtteranceID, &triggered.Summary, &triggered.TriggerAtUnixMillis, &triggered.State, &triggered.Revision)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return continuity.ProspectiveItem{}, continuity.AttentionItem{}, ErrProspectiveRevisionConflict
|
||||
}
|
||||
if err != nil {
|
||||
return continuity.ProspectiveItem{}, continuity.AttentionItem{}, fmt.Errorf("trigger due reminder: %w", err)
|
||||
}
|
||||
|
||||
var revalidating continuity.ProspectiveItem
|
||||
err = tx.QueryRow(ctx, `UPDATE continuity.prospective_items
|
||||
SET state=$1, revision=revision+1
|
||||
WHERE prospective_item_id=$2 AND state=$3 AND revision=$4
|
||||
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.ProspectiveRevalidating, triggered.ID, continuity.ProspectiveTriggered, triggered.Revision).Scan(
|
||||
&revalidating.ID, &revalidating.CommitmentID, &revalidating.OwnerPrincipalID, &revalidating.ScopeID, &revalidating.GoalID,
|
||||
&revalidating.SourceIntentID, &revalidating.SourceUtteranceID, &revalidating.Summary, &revalidating.TriggerAtUnixMillis, &revalidating.State, &revalidating.Revision)
|
||||
if err != nil {
|
||||
return continuity.ProspectiveItem{}, continuity.AttentionItem{}, fmt.Errorf("begin due reminder revalidation: %w", err)
|
||||
}
|
||||
|
||||
var delivered continuity.ProspectiveItem
|
||||
err = tx.QueryRow(ctx, `UPDATE continuity.prospective_items
|
||||
SET state=$1, revision=revision+1
|
||||
WHERE prospective_item_id=$2 AND state=$3 AND revision=$4
|
||||
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.ProspectiveDelivered, revalidating.ID, continuity.ProspectiveRevalidating, revalidating.Revision).Scan(
|
||||
&delivered.ID, &delivered.CommitmentID, &delivered.OwnerPrincipalID, &delivered.ScopeID, &delivered.GoalID,
|
||||
&delivered.SourceIntentID, &delivered.SourceUtteranceID, &delivered.Summary, &delivered.TriggerAtUnixMillis, &delivered.State, &delivered.Revision)
|
||||
if err != nil {
|
||||
return continuity.ProspectiveItem{}, continuity.AttentionItem{}, fmt.Errorf("finish due reminder revalidation: %w", err)
|
||||
}
|
||||
|
||||
attention, err := continuitydomain.BuildDueReminderAttention(delivered, attentionID, nowUnixMillis)
|
||||
if err != nil {
|
||||
return continuity.ProspectiveItem{}, continuity.AttentionItem{}, err
|
||||
}
|
||||
err = tx.QueryRow(ctx, `INSERT INTO continuity.attention_items (
|
||||
attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,
|
||||
due_at_unix_millis,created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,
|
||||
action_attempt_id,delivery_outcome,response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,0,'','','','','','','','',0,1)
|
||||
ON CONFLICT (source_kind, source_prospective_item_id) DO UPDATE SET
|
||||
state=EXCLUDED.state,
|
||||
due_at_unix_millis=EXCLUDED.due_at_unix_millis,
|
||||
created_at_unix_millis=EXCLUDED.created_at_unix_millis,
|
||||
delivered_at_unix_millis=0,
|
||||
action_intent_id='',
|
||||
authorized_action_id='',
|
||||
action_attempt_id='',
|
||||
delivery_outcome='',
|
||||
response_id='',
|
||||
response_utterance_id='',
|
||||
response_operation='',
|
||||
response_outcome='',
|
||||
responded_at_unix_millis=0,
|
||||
revision=continuity.attention_items.revision+1
|
||||
RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,due_at_unix_millis,
|
||||
created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision`,
|
||||
attention.ID, attention.OwnerPrincipalID, attention.ScopeID, attention.SourceKind, attention.SourceProspectiveID,
|
||||
attention.Level, attention.State, attention.Summary, attention.DueAtUnixMillis, attention.CreatedAtUnixMillis).Scan(
|
||||
&attention.ID, &attention.OwnerPrincipalID, &attention.ScopeID, &attention.SourceKind, &attention.SourceProspectiveID,
|
||||
&attention.Level, &attention.State, &attention.Summary, &attention.DueAtUnixMillis, &attention.CreatedAtUnixMillis,
|
||||
&attention.DeliveredAtUnixMillis, &attention.ActionIntentID, &attention.AuthorizedActionID, &attention.ActionAttemptID,
|
||||
&attention.DeliveryOutcome, &attention.ResponseID, &attention.ResponseUtteranceID, &attention.ResponseOperation,
|
||||
&attention.ResponseOutcome, &attention.RespondedAtUnixMillis, &attention.Revision)
|
||||
if err != nil {
|
||||
return continuity.ProspectiveItem{}, continuity.AttentionItem{}, fmt.Errorf("create due reminder attention: %w", err)
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return continuity.ProspectiveItem{}, continuity.AttentionItem{}, err
|
||||
}
|
||||
return delivered, attention, nil
|
||||
}
|
||||
|
||||
func (r *ContinuityRepository) PendingAttention(ctx context.Context, limit int) ([]continuity.AttentionItem, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, fmt.Errorf("invalid attention limit")
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `SELECT attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,
|
||||
due_at_unix_millis,created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision
|
||||
FROM continuity.attention_items WHERE state=$1 ORDER BY due_at_unix_millis, attention_item_id LIMIT $2`, continuity.AttentionPending, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list pending attention: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAttentionRows(rows)
|
||||
}
|
||||
|
||||
func (r *ContinuityRepository) AttentionByOwner(ctx context.Context, owner identity.PrincipalID, limit int) ([]continuity.AttentionItem, error) {
|
||||
if owner == "" || limit < 1 || limit > 100 {
|
||||
return nil, fmt.Errorf("invalid attention owner query")
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `SELECT attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,
|
||||
due_at_unix_millis,created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision
|
||||
FROM continuity.attention_items WHERE owner_principal_id=$1 ORDER BY due_at_unix_millis DESC, attention_item_id DESC LIMIT $2`, owner, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list attention by owner: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAttentionRows(rows)
|
||||
}
|
||||
|
||||
func (r *ContinuityRepository) ResponseCandidates(ctx context.Context, owner identity.PrincipalID, limit int) ([]continuitydomain.ResponseCandidate, error) {
|
||||
if owner == "" || limit < 1 || limit > 10 {
|
||||
return nil, fmt.Errorf("invalid response candidate query")
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `SELECT
|
||||
COALESCE(a.attention_item_id,''),COALESCE(a.owner_principal_id,''),COALESCE(a.scope_id,''),COALESCE(a.source_kind,''),COALESCE(a.source_prospective_item_id,''),COALESCE(a.level,''),COALESCE(a.state,''),COALESCE(a.summary,''),
|
||||
COALESCE(a.due_at_unix_millis,0),COALESCE(a.created_at_unix_millis,0),COALESCE(a.delivered_at_unix_millis,0),COALESCE(a.action_intent_id,''),COALESCE(a.authorized_action_id,''),COALESCE(a.action_attempt_id,''),COALESCE(a.delivery_outcome,''),
|
||||
COALESCE(a.response_id,''),COALESCE(a.response_utterance_id,''),COALESCE(a.response_operation,''),COALESCE(a.response_outcome,''),COALESCE(a.responded_at_unix_millis,0),COALESCE(a.revision,0),
|
||||
p.prospective_item_id,p.commitment_id,p.owner_principal_id,p.scope_id,p.goal_id,p.source_intent_id,p.source_utterance_id,p.summary,p.trigger_at_unix_millis,p.state,p.revision
|
||||
FROM continuity.prospective_items p
|
||||
LEFT JOIN continuity.attention_items a ON a.source_kind=$2 AND a.source_prospective_item_id=p.prospective_item_id AND a.owner_principal_id=p.owner_principal_id
|
||||
WHERE p.owner_principal_id=$1
|
||||
AND (a.attention_item_id IS NULL OR a.state IN ($3,$4,$5))
|
||||
AND p.state IN ($6,$7)
|
||||
ORDER BY COALESCE(a.delivered_at_unix_millis,0) DESC, p.trigger_at_unix_millis DESC, p.prospective_item_id DESC
|
||||
LIMIT $8`,
|
||||
owner, continuity.AttentionSourceProspectiveReminder, continuity.AttentionDelivered, continuity.AttentionPending,
|
||||
continuity.AttentionFailed, continuity.ProspectiveDelivered, continuity.ProspectiveArmed, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list human response candidates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var candidates []continuitydomain.ResponseCandidate
|
||||
for rows.Next() {
|
||||
var candidate continuitydomain.ResponseCandidate
|
||||
var attentionID, attentionOwner, attentionScope, sourceKind, sourceProspective, level, attentionState, attentionSummary string
|
||||
var actionIntent, authorizedAction, actionAttempt, deliveryOutcome, responseID, responseUtterance, responseOperation, responseOutcome string
|
||||
var dueAt, createdAt, deliveredAt, respondedAt int64
|
||||
var attentionRevision uint64
|
||||
if err := rows.Scan(
|
||||
&attentionID, &attentionOwner, &attentionScope, &sourceKind, &sourceProspective, &level, &attentionState, &attentionSummary,
|
||||
&dueAt, &createdAt, &deliveredAt, &actionIntent, &authorizedAction, &actionAttempt, &deliveryOutcome,
|
||||
&responseID, &responseUtterance, &responseOperation, &responseOutcome, &respondedAt, &attentionRevision,
|
||||
&candidate.Prospective.ID, &candidate.Prospective.CommitmentID, &candidate.Prospective.OwnerPrincipalID,
|
||||
&candidate.Prospective.ScopeID, &candidate.Prospective.GoalID, &candidate.Prospective.SourceIntentID,
|
||||
&candidate.Prospective.SourceUtteranceID, &candidate.Prospective.Summary, &candidate.Prospective.TriggerAtUnixMillis,
|
||||
&candidate.Prospective.State, &candidate.Prospective.Revision); err != nil {
|
||||
return nil, fmt.Errorf("scan human response candidate: %w", err)
|
||||
}
|
||||
if attentionID != "" {
|
||||
candidate.Attention = continuity.AttentionItem{
|
||||
ID: continuity.AttentionItemID(attentionID),
|
||||
OwnerPrincipalID: identity.PrincipalID(attentionOwner),
|
||||
ScopeID: authority.ScopeID(attentionScope),
|
||||
SourceKind: continuity.AttentionSourceKind(sourceKind),
|
||||
SourceProspectiveID: continuity.ProspectiveItemID(sourceProspective),
|
||||
Level: continuity.AttentionLevel(level),
|
||||
State: continuity.AttentionState(attentionState),
|
||||
Summary: attentionSummary,
|
||||
DueAtUnixMillis: dueAt,
|
||||
CreatedAtUnixMillis: createdAt,
|
||||
DeliveredAtUnixMillis: deliveredAt,
|
||||
ActionIntentID: continuity.CommittedActionIntentID(actionIntent),
|
||||
AuthorizedActionID: continuity.CommittedAuthorizedActionID(authorizedAction),
|
||||
ActionAttemptID: continuity.CommittedActionAttemptID(actionAttempt),
|
||||
DeliveryOutcome: deliveryOutcome,
|
||||
ResponseID: continuity.HumanResponseID(responseID),
|
||||
ResponseUtteranceID: responseUtterance,
|
||||
ResponseOperation: continuity.HumanResponseOperation(responseOperation),
|
||||
ResponseOutcome: continuity.ResponseOutcome(responseOutcome),
|
||||
RespondedAtUnixMillis: respondedAt,
|
||||
Revision: continuity.AttentionRevision(attentionRevision),
|
||||
}
|
||||
}
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list human response candidates: %w", err)
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (r *ContinuityRepository) ApplyHumanResponse(ctx context.Context, response continuity.HumanResponse) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO continuity.human_responses (
|
||||
human_response_id,owner_principal_id,scope_id,source_utterance_id,source_intent_id,operation,target_kind,
|
||||
target_attention_id,target_prospective_item_id,target_external_ref,resolution_state,outcome,clarification,
|
||||
snooze_until_unix_millis,user_reported_not_verified,recorded_at_unix_millis,revision)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`,
|
||||
response.ID, response.OwnerPrincipalID, response.ScopeID, response.SourceUtteranceID, response.SourceIntentID,
|
||||
response.Operation, response.TargetKind, response.TargetAttentionID, response.TargetProspectiveID, response.TargetExternalRef,
|
||||
response.ResolutionState, response.Outcome, response.Clarification, response.SnoozeUntilUnixMillis,
|
||||
response.UserReportedNotVerified, response.RecordedAtUnixMillis, response.Revision); err != nil {
|
||||
return fmt.Errorf("save human response: %w", err)
|
||||
}
|
||||
if response.ResolutionState != continuity.ResponseResolved {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
switch response.Operation {
|
||||
case continuity.ResponseComplete:
|
||||
tag, err := tx.Exec(ctx, `UPDATE continuity.prospective_items SET state=$1, revision=revision+1
|
||||
WHERE prospective_item_id=$2 AND owner_principal_id=$3 AND state IN ($4,$5)`,
|
||||
continuity.ProspectiveSatisfied, response.TargetProspectiveID, response.OwnerPrincipalID,
|
||||
continuity.ProspectiveDelivered, continuity.ProspectiveArmed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete response prospective: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return ErrProspectiveStateConflict
|
||||
}
|
||||
if response.TargetAttentionID == "" {
|
||||
return ErrAttentionNotFound
|
||||
}
|
||||
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionResolved); err != nil {
|
||||
return err
|
||||
}
|
||||
case continuity.ResponseSnooze:
|
||||
tag, err := tx.Exec(ctx, `UPDATE continuity.prospective_items SET state=$1, trigger_at_unix_millis=$2, revision=revision+1
|
||||
WHERE prospective_item_id=$3 AND owner_principal_id=$4 AND state IN ($5,$6)`,
|
||||
continuity.ProspectiveArmed, response.SnoozeUntilUnixMillis, response.TargetProspectiveID,
|
||||
response.OwnerPrincipalID, continuity.ProspectiveDelivered, continuity.ProspectiveArmed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("snooze response prospective: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return ErrProspectiveStateConflict
|
||||
}
|
||||
if response.TargetAttentionID != "" {
|
||||
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionResolved); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case continuity.ResponseCancel:
|
||||
tag, err := tx.Exec(ctx, `UPDATE continuity.prospective_items SET state=$1, revision=revision+1
|
||||
WHERE prospective_item_id=$2 AND owner_principal_id=$3 AND state IN ($4,$5)`,
|
||||
continuity.ProspectiveSuppressed, response.TargetProspectiveID, response.OwnerPrincipalID,
|
||||
continuity.ProspectiveDelivered, continuity.ProspectiveArmed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cancel response prospective: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return ErrProspectiveStateConflict
|
||||
}
|
||||
if response.TargetAttentionID != "" {
|
||||
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionSuppressed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case continuity.ResponseAcknowledge, continuity.ResponseDismiss:
|
||||
if response.TargetAttentionID == "" {
|
||||
return ErrAttentionNotFound
|
||||
}
|
||||
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionResolved); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *ContinuityRepository) MarkAttentionDelivering(ctx context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision) (continuity.AttentionItem, error) {
|
||||
var item continuity.AttentionItem
|
||||
err := r.pool.QueryRow(ctx, `UPDATE continuity.attention_items SET state=$1, revision=revision+1
|
||||
WHERE attention_item_id=$2 AND state=$3 AND revision=$4
|
||||
RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,due_at_unix_millis,
|
||||
created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision`,
|
||||
continuity.AttentionDelivering, id, continuity.AttentionPending, revision).Scan(
|
||||
&item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID, &item.Level, &item.State,
|
||||
&item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis, &item.DeliveredAtUnixMillis, &item.ActionIntentID,
|
||||
&item.AuthorizedActionID, &item.ActionAttemptID, &item.DeliveryOutcome, &item.ResponseID, &item.ResponseUtteranceID,
|
||||
&item.ResponseOperation, &item.ResponseOutcome, &item.RespondedAtUnixMillis, &item.Revision)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return continuity.AttentionItem{}, ErrAttentionRevisionConflict
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (r *ContinuityRepository) MarkAttentionDelivered(ctx context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision, intentID continuity.CommittedActionIntentID, authorizedID continuity.CommittedAuthorizedActionID, attemptID continuity.CommittedActionAttemptID, outcome string, deliveredAtUnixMillis int64) (continuity.AttentionItem, error) {
|
||||
var item continuity.AttentionItem
|
||||
err := r.pool.QueryRow(ctx, `UPDATE continuity.attention_items
|
||||
SET state=$1, revision=revision+1, delivered_at_unix_millis=$2, action_intent_id=$3, authorized_action_id=$4,
|
||||
action_attempt_id=$5, delivery_outcome=$6
|
||||
WHERE attention_item_id=$7 AND state=$8 AND revision=$9
|
||||
RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,due_at_unix_millis,
|
||||
created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision`,
|
||||
continuity.AttentionDelivered, deliveredAtUnixMillis, intentID, authorizedID, attemptID, outcome,
|
||||
id, continuity.AttentionDelivering, revision).Scan(
|
||||
&item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID, &item.Level, &item.State,
|
||||
&item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis, &item.DeliveredAtUnixMillis, &item.ActionIntentID,
|
||||
&item.AuthorizedActionID, &item.ActionAttemptID, &item.DeliveryOutcome, &item.ResponseID, &item.ResponseUtteranceID,
|
||||
&item.ResponseOperation, &item.ResponseOutcome, &item.RespondedAtUnixMillis, &item.Revision)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return continuity.AttentionItem{}, ErrAttentionRevisionConflict
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (r *ContinuityRepository) MarkAttentionFailed(ctx context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision, intentID continuity.CommittedActionIntentID, authorizedID continuity.CommittedAuthorizedActionID, attemptID continuity.CommittedActionAttemptID, outcome string) (continuity.AttentionItem, error) {
|
||||
var item continuity.AttentionItem
|
||||
err := r.pool.QueryRow(ctx, `UPDATE continuity.attention_items
|
||||
SET state=$1, revision=revision+1, action_intent_id=$2, authorized_action_id=$3,
|
||||
action_attempt_id=$4, delivery_outcome=$5
|
||||
WHERE attention_item_id=$6 AND state=$7 AND revision=$8
|
||||
RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,due_at_unix_millis,
|
||||
created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision`,
|
||||
continuity.AttentionFailed, intentID, authorizedID, attemptID, outcome,
|
||||
id, continuity.AttentionDelivering, revision).Scan(
|
||||
&item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID, &item.Level, &item.State,
|
||||
&item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis, &item.DeliveredAtUnixMillis, &item.ActionIntentID,
|
||||
&item.AuthorizedActionID, &item.ActionAttemptID, &item.DeliveryOutcome, &item.ResponseID, &item.ResponseUtteranceID,
|
||||
&item.ResponseOperation, &item.ResponseOutcome, &item.RespondedAtUnixMillis, &item.Revision)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return continuity.AttentionItem{}, ErrAttentionRevisionConflict
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
// SatisfyDelivered owns both the Prospective CAS and its idempotency receipt in
|
||||
// one Continuity-local transaction. It never reaches Commit storage.
|
||||
func (r *ContinuityRepository) SatisfyDelivered(ctx context.Context, command continuity.SatisfyDeliveredCommand) (continuity.ActionReceipt, error) {
|
||||
@@ -95,10 +455,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)
|
||||
@@ -135,6 +495,51 @@ type continuityRowQuerier interface {
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
}
|
||||
|
||||
type continuityExecutor interface {
|
||||
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
|
||||
}
|
||||
|
||||
type continuityRows interface {
|
||||
Next() bool
|
||||
Scan(...any) error
|
||||
Err() error
|
||||
}
|
||||
|
||||
func markAttentionResponded(ctx context.Context, executor continuityExecutor, response continuity.HumanResponse, state continuity.AttentionState) error {
|
||||
tag, err := executor.Exec(ctx, `UPDATE continuity.attention_items
|
||||
SET state=$1, response_id=$2, response_utterance_id=$3, response_operation=$4, response_outcome=$5,
|
||||
responded_at_unix_millis=$6, revision=revision+1
|
||||
WHERE attention_item_id=$7 AND owner_principal_id=$8 AND state IN ($9,$10,$11)`,
|
||||
state, response.ID, response.SourceUtteranceID, response.Operation, response.Outcome, response.RecordedAtUnixMillis,
|
||||
response.TargetAttentionID, response.OwnerPrincipalID, continuity.AttentionDelivered, continuity.AttentionPending, continuity.AttentionFailed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark attention responded: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return ErrAttentionStateConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanAttentionRows(rows continuityRows) ([]continuity.AttentionItem, error) {
|
||||
items := make([]continuity.AttentionItem, 0)
|
||||
for rows.Next() {
|
||||
var item continuity.AttentionItem
|
||||
if err := rows.Scan(&item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID,
|
||||
&item.Level, &item.State, &item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis,
|
||||
&item.DeliveredAtUnixMillis, &item.ActionIntentID, &item.AuthorizedActionID, &item.ActionAttemptID,
|
||||
&item.DeliveryOutcome, &item.ResponseID, &item.ResponseUtteranceID, &item.ResponseOperation,
|
||||
&item.ResponseOutcome, &item.RespondedAtUnixMillis, &item.Revision); err != nil {
|
||||
return nil, fmt.Errorf("scan attention item: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("scan attention items: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func loadActionReceipt(ctx context.Context, querier continuityRowQuerier, intentID continuity.CommittedActionIntentID) (continuity.ActionReceipt, error) {
|
||||
var receipt continuity.ActionReceipt
|
||||
err := querier.QueryRow(ctx, `SELECT action_intent_id,authorized_action_id,owner_principal_id,prospective_item_id,
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,46 @@
|
||||
# ADR-0014 — Assistant Presence, Attention, and Human Surface V1
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Task: DEV-000065
|
||||
|
||||
## Context
|
||||
|
||||
Domovoy already records human utterances, original human intent, current focus, capability requirements, capability gaps, and Continuity-owned reminder state. Before this decision, creating a reminder only persisted an `ARMED` `ProspectiveItem`; it did not by itself return to the human when the due condition arrived.
|
||||
|
||||
The system already has the correct outbound mutation foundation: Commit owns authorized external actions, Capability owns notification bindings, and the notification receiver persists idempotent receipts. DEV-000065 must therefore connect due Continuity state to the existing Commit/Executor path instead of creating a parallel reminder or notification subsystem.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Continuity remains the canonical owner of future obligations and prospective state.
|
||||
2. Continuity also owns the V1 typed `AttentionItem` state because attention is the human-facing relevance decision derived from future obligations and other future internal events.
|
||||
3. `AttentionItem` V1 is deliberately narrow: it records source, owner, level, state, summary, due time, and delivery evidence references. It is not a task manager, dashboard model, or capability registry.
|
||||
4. A due reminder follows the existing prospective lifecycle: `ARMED → TRIGGERED → REVALIDATING → DELIVERED`. `DELIVERED` means the due obligation has reached the human-contact phase; it does not grant external mutation authority by itself.
|
||||
5. Human notification remains a Commit-owned external action using the existing `deliver-prospective-notification-v1` operation and Capability-owned active notification binding.
|
||||
6. Commit may authorize a prospective notification using a Continuity prospective item as the live justification. A Work `TaskTarget` is not mandatory for this operation when the operation is internally invoked for a validated due `ProspectiveItem`.
|
||||
7. The due processing runtime may poll canonical PostgreSQL state, but correctness must come from persisted state transitions and idempotency constraints, not from process-local timers.
|
||||
8. The Human Assistant web surface is separate from the Operator Console. Chat remains primary; Today/Attention and History are human-readable projections built through runtime APIs over canonical state.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Continuity writes only Continuity-owned prospective and attention state.
|
||||
- Commit writes only Commit-owned action/attempt state and invokes the existing notification executor.
|
||||
- Capability owns the notification binding and health/availability semantics.
|
||||
- Interaction owns conversations, utterances, interpretations, and original human intent.
|
||||
- No model/provider call is required for deterministic reminder firing.
|
||||
- Model output never directly mutates Continuity, Commit, or Capability state.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No Gmail, MCP, Codex Bridge, browser automation, mobile app, voice, or self-extension loop.
|
||||
- No new canonical owner.
|
||||
- No generic scheduler/event-bus/autonomous-agent loop.
|
||||
- No new third-party dependency.
|
||||
- No new notification channel beyond the existing receiver/Windows display path.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Reminders survive runtime restart because due detection scans canonical `ProspectiveItem` rows.
|
||||
- Duplicate notification is prevented by Continuity CAS/unique attention source and Commit/receiver idempotency.
|
||||
- The first attention source is reminder due events; future sources can reuse the same attention path without changing ownership.
|
||||
- Human UI no longer needs internal UUIDs, binding IDs, provider IDs, or raw lifecycle enums for ordinary use.
|
||||
@@ -0,0 +1,42 @@
|
||||
# ADR-0015 — Human Response and Continuation Semantics V1
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Task: DEV-000066
|
||||
|
||||
## Context
|
||||
|
||||
DEV-000065 made Domovoy present beyond an HTTP request: reminders can become due, produce Continuity-owned Attention, and deliver a governed notification. The next step is not a reminder command set; it is a general way to interpret a human response to an existing context.
|
||||
|
||||
Examples include: a reminder after notification, waiting work requiring a decision, a future email thread, or a future capability gap/acquisition context. V1 implements reminders as the first real target while preserving extensible typed target references for future owners.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Continuity owns `HumanResponse` and `ResponseTargetResolution` V1 because they continue durable waits/attention and may mutate Continuity-owned future obligation state.
|
||||
2. Interaction remains the owner of the original `HumanUtterance` and `OriginalHumanIntent`; Continuity stores references to those records, not copies of their authority.
|
||||
3. V1 response operations are typed but extensible: `ACKNOWLEDGE`, `COMPLETE`, `SNOOZE`, `CANCEL`, `DISMISS`, `CONTINUE`, `REJECT`, plus `UNKNOWN` for fail-closed interpretation.
|
||||
4. V1 target references are typed: `ATTENTION_ITEM`, `PROSPECTIVE_ITEM`, `WORK_TASK`, `CAPABILITY_GAP`, `INTERACTION_CONTEXT`, and `UNKNOWN`. DEV-000066 implements only Continuity-owned reminder/attention mutations.
|
||||
5. Obvious deterministic responses may execute without cognition. Ambiguous responses must ask for clarification and record an append-only response record without mutating the target.
|
||||
6. A human response is not automatic verified world truth. For reminder completion, V1 records `USER_REPORTED_COMPLETE`; this satisfies the reminder according to policy but does not create a verified external-world fact.
|
||||
7. UI buttons, if present, must post the same semantic response text/API path as natural chat. They are projections, not a separate command system.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Continuity writes only Continuity-owned response, attention, and prospective state.
|
||||
- Interaction writes conversations/utterances/intents before Continuity response handling.
|
||||
- No external mutation or Commit/Executor operation is introduced in DEV-000066.
|
||||
- No model output directly mutates canonical state.
|
||||
- Future Codex/mail/capability-gap continuations are representable by typed references, but not implemented.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No Codex Bridge, Gmail, MCP, voice, mobile, browser automation, self-extension loop, or generalized orchestration.
|
||||
- No universal object ontology.
|
||||
- No reminder-specific parallel subsystem.
|
||||
|
||||
## Consequences
|
||||
|
||||
- “Готово”, “Отложи”, “Отмени” can continue the latest obvious reminder/attention context without internal IDs.
|
||||
- Snooze re-arms the same `ProspectiveItem` with a new due time, preserving audit history.
|
||||
- Cancel suppresses the future obligation so the due scanner will not deliver it.
|
||||
- Today/Attention can show active, snoozed, completed, and cancelled human-readable states.
|
||||
@@ -98,7 +98,9 @@
|
||||
"id": "mod.continuity",
|
||||
"owns": [
|
||||
"Commitment",
|
||||
"ProspectiveItem"
|
||||
"ProspectiveItem",
|
||||
"AttentionItem",
|
||||
"HumanResponse"
|
||||
],
|
||||
"database_schema": "continuity",
|
||||
"boundary_class": "remote_capable",
|
||||
@@ -114,7 +116,13 @@
|
||||
"owns": [
|
||||
"Capability",
|
||||
"CapabilityBinding",
|
||||
"CapabilityHealth"
|
||||
"CapabilityHealth",
|
||||
"CapabilityImplementation",
|
||||
"CapabilityResolution",
|
||||
"CapabilitySelection",
|
||||
"CapabilityGap",
|
||||
"AcquisitionStrategyCandidate",
|
||||
"CapabilityUsageObservation"
|
||||
],
|
||||
"database_schema": "capability",
|
||||
"boundary_class": "remote_capable",
|
||||
|
||||
@@ -41,14 +41,14 @@ modules:
|
||||
|
||||
continuity:
|
||||
id: mod.continuity
|
||||
owns: [Commitment, ProspectiveItem]
|
||||
owns: [Commitment, ProspectiveItem, AttentionItem, HumanResponse]
|
||||
database_schema: continuity
|
||||
boundary_class: remote_capable
|
||||
may_depend_on: [contracts.work, contracts.identity, contracts.authority, platform.events]
|
||||
|
||||
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]
|
||||
|
||||
@@ -183,3 +183,29 @@ func (c creatorContinuityClient) Item(ctx context.Context, authorization string,
|
||||
return continuity.ProspectiveItem{}, errCreatorQueryUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
func (c creatorContinuityClient) InternalItem(ctx context.Context, id continuity.ProspectiveItemID) (continuity.ProspectiveItem, error) {
|
||||
target := *c.baseURL
|
||||
target.Path = "/v1/prospective-items/" + url.PathEscape(string(id))
|
||||
target.RawQuery = ""
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
|
||||
if err != nil {
|
||||
return continuity.ProspectiveItem{}, errCreatorQueryUnavailable
|
||||
}
|
||||
response, err := c.client.Do(request)
|
||||
if err != nil {
|
||||
return continuity.ProspectiveItem{}, fmt.Errorf("%w: %v", errCreatorQueryUnavailable, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return continuity.ProspectiveItem{}, errCreatorSubjectMissing
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return continuity.ProspectiveItem{}, errCreatorQueryUnavailable
|
||||
}
|
||||
var item continuity.ProspectiveItem
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 32<<10)).Decode(&item); err != nil {
|
||||
return continuity.ProspectiveItem{}, errCreatorQueryUnavailable
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/notificationexecutor"
|
||||
commitcontract "git.cin.su/m/Assistant/contracts/commit"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
commitdomain "git.cin.su/m/Assistant/modules/commit/domain"
|
||||
"git.cin.su/m/Assistant/modules/commit/ports"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
const prospectiveNotificationActor identity.ActorID = "domovoy-creator-actor"
|
||||
|
||||
type internalNotificationContinuity interface {
|
||||
InternalItem(context.Context, continuity.ProspectiveItemID) (continuity.ProspectiveItem, error)
|
||||
}
|
||||
|
||||
type internalNotificationHandler struct {
|
||||
core creatorSessionSource
|
||||
continuity internalNotificationContinuity
|
||||
authority creatorAuthoritySource
|
||||
repository creatorCommitRepository
|
||||
executor ports.Executor
|
||||
newID func() (ids.ID, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type internalProspectiveNotificationRequest struct {
|
||||
ProspectiveItemID continuity.ProspectiveItemID `json:"prospective_item_id"`
|
||||
ExpectedRevision continuity.ProspectiveRevision `json:"expected_revision"`
|
||||
}
|
||||
|
||||
func registerInternalNotificationRoutes(mux *http.ServeMux, handler internalNotificationHandler, token string) {
|
||||
mux.HandleFunc("POST /v1/internal/continuity/notify-due", requireCommitToken(token, handler.notifyDue))
|
||||
}
|
||||
|
||||
func (h internalNotificationHandler) notifyDue(w http.ResponseWriter, r *http.Request) {
|
||||
var request internalProspectiveNotificationRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || ensureCreatorEOF(decoder) != nil || request.ProspectiveItemID == "" || request.ExpectedRevision == 0 {
|
||||
http.Error(w, "invalid prospective notification request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
item, err := h.continuity.InternalItem(r.Context(), request.ProspectiveItemID)
|
||||
if errors.Is(err, errCreatorSubjectMissing) {
|
||||
http.Error(w, "prospective item not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "Continuity item unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if item.State != continuity.ProspectiveDelivered || item.Revision != request.ExpectedRevision || item.OwnerPrincipalID == "" || item.ScopeID == "" {
|
||||
http.Error(w, "prospective item not deliverable", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
grant, scope, err := h.authority.LoadGrant(r.Context(), creatorGrantID)
|
||||
if err != nil || scope.ID != item.ScopeID {
|
||||
http.Error(w, "Creator authority unavailable", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
identityBinding, err := h.core.Binding(r.Context(), prospectiveNotificationActor, item.OwnerPrincipalID)
|
||||
if err != nil {
|
||||
http.Error(w, "Creator identity binding unavailable", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
capabilityBinding, err := h.core.ActiveCapabilityBinding(r.Context(), commitcontract.CapabilityProspectiveNotification, item.OwnerPrincipalID)
|
||||
if err != nil {
|
||||
http.Error(w, "notification capability unavailable", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
intentID, authorizedID, attemptID, err := internalActionIDs(h.newID)
|
||||
if err != nil {
|
||||
http.Error(w, "action identity unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
title := "Домовой: напоминание"
|
||||
message := "Пора: " + strings.TrimSpace(item.Summary)
|
||||
payload := notificationexecutor.Payload{ActionIntentID: intentID, TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, ProspectiveItemID: string(item.ID), ExpectedRevision: int64(item.Revision), Title: title, Message: message}
|
||||
digest, err := notificationexecutor.Digest(payload)
|
||||
if err != nil {
|
||||
http.Error(w, "notification evidence unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
proposal := commitcontract.ActionProposal{
|
||||
IntentID: intentID, ActorID: prospectiveNotificationActor, CapabilityID: commitcontract.CapabilityProspectiveNotification,
|
||||
Target: work.TaskTarget{ScopeID: item.ScopeID, Revision: 1}, Operation: commitcontract.OperationDeliverProspectiveNotification,
|
||||
NotificationSubject: commitcontract.ProspectiveNotificationSubject{ProspectiveItemID: item.ID, ExpectedRevision: item.Revision, TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, BindingID: capabilityBinding.ID, BindingRevision: capabilityBinding.Revision, RenderedTitle: title, RenderedMessage: message, RequestDigest: digest},
|
||||
}
|
||||
action, err := commitdomain.Authorize(proposal, commitdomain.AuthorizationContext{Binding: identityBinding, Scope: scope, Grant: grant}, authorityValidator{}, authorizedID, h.now().UnixMilli())
|
||||
if err != nil {
|
||||
http.Error(w, "Creator authorization denied", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if err = h.repository.SaveAuthorized(r.Context(), action); err != nil {
|
||||
http.Error(w, "commit authorized action", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
attempt, operationErr := executeAuthorized(r.Context(), h.repository, h.executor, action, attemptID)
|
||||
if operationErr != nil && !errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
|
||||
http.Error(w, "execute committed notification", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
status := http.StatusCreated
|
||||
if errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
|
||||
status = http.StatusAccepted
|
||||
}
|
||||
writeCreatorActionResponse(w, status, action, attempt)
|
||||
}
|
||||
|
||||
func internalActionIDs(newID func() (ids.ID, error)) (commitcontract.ActionIntentID, commitcontract.AuthorizedActionID, commitcontract.ActionAttemptID, error) {
|
||||
intent, err := newID()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
authorized, err := newID()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
attempt, err := newID()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return commitcontract.ActionIntentID(intent), commitcontract.AuthorizedActionID(authorized), commitcontract.ActionAttemptID(attempt), nil
|
||||
}
|
||||
@@ -89,6 +89,10 @@ func main() {
|
||||
core: creatorCore, continuity: creatorContinuity, authority: authorityRepository, repository: repository,
|
||||
executor: dispatcher, reconciler: dispatcher, newID: ids.New, now: time.Now,
|
||||
})
|
||||
registerInternalNotificationRoutes(mux, internalNotificationHandler{
|
||||
core: creatorCore, continuity: creatorContinuity, authority: authorityRepository, repository: repository,
|
||||
executor: dispatcher, newID: ids.New, now: time.Now,
|
||||
}, executorToken)
|
||||
mux.HandleFunc("POST /v1/authorized-actions", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request authorizationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
type attentionRepository interface {
|
||||
AttentionByOwner(context.Context, identity.PrincipalID, int) ([]continuity.AttentionItem, error)
|
||||
}
|
||||
|
||||
func registerCreatorAttentionRoutes(mux *http.ServeMux, identityClient creatorIdentityClient, repository attentionRepository) {
|
||||
mux.HandleFunc("GET /v1/creator/attention", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := creatorSessionForAttention(w, r, identityClient)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit, err := parseListLimit(r.URL.Query().Get("limit"))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid attention limit", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
items, err := repository.AttentionByOwner(r.Context(), session.PrincipalID, limit)
|
||||
if err != nil {
|
||||
http.Error(w, "attention unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(continuity.AttentionListResult{Items: items})
|
||||
})
|
||||
mux.HandleFunc("GET /v1/creator/today", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := creatorSessionForAttention(w, r, identityClient)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := repository.AttentionByOwner(r.Context(), session.PrincipalID, 50)
|
||||
if err != nil {
|
||||
http.Error(w, "today unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
result := continuity.TodayResult{Items: make([]continuity.TodayItem, 0, len(items))}
|
||||
for _, item := range items {
|
||||
result.Items = append(result.Items, continuity.TodayItem{
|
||||
Title: item.Summary,
|
||||
WhenUnixMillis: item.DueAtUnixMillis,
|
||||
AttentionLevel: item.Level,
|
||||
AttentionState: item.State,
|
||||
HumanState: humanAttentionState(item.State),
|
||||
DeliveryState: humanDeliveryState(item),
|
||||
SourceDescription: humanAttentionSource(item.SourceKind),
|
||||
ResponseHint: humanResponseHint(item),
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
}
|
||||
|
||||
func creatorSessionForAttention(w http.ResponseWriter, r *http.Request, identityClient creatorIdentityClient) (identity.CreatorSession, bool) {
|
||||
session, err := identityClient.Session(r.Context(), r.Header.Get("Authorization"))
|
||||
if errors.Is(err, errCreatorSessionDenied) {
|
||||
http.Error(w, "creator session denied", http.StatusUnauthorized)
|
||||
return identity.CreatorSession{}, false
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "identity service unavailable", http.StatusServiceUnavailable)
|
||||
return identity.CreatorSession{}, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
|
||||
func humanAttentionState(state continuity.AttentionState) string {
|
||||
switch state {
|
||||
case continuity.AttentionPending:
|
||||
return "ожидает внимания"
|
||||
case continuity.AttentionDelivering:
|
||||
return "доставляется"
|
||||
case continuity.AttentionDelivered:
|
||||
return "доставлено"
|
||||
case continuity.AttentionResolved:
|
||||
return "закрыто"
|
||||
case continuity.AttentionSuppressed:
|
||||
return "скрыто"
|
||||
case continuity.AttentionExpired:
|
||||
return "истекло"
|
||||
case continuity.AttentionFailed:
|
||||
return "не доставлено"
|
||||
default:
|
||||
return "состояние уточняется"
|
||||
}
|
||||
}
|
||||
|
||||
func humanResponseHint(item continuity.AttentionItem) string {
|
||||
if item.State == continuity.AttentionDelivered || item.State == continuity.AttentionPending || item.State == continuity.AttentionFailed {
|
||||
return "Можно ответить: «Готово», «Отложи на 10 минут» или «Отмени»."
|
||||
}
|
||||
if item.ResponseOutcome != "" {
|
||||
return "Ответ записан: " + string(item.ResponseOutcome)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func humanDeliveryState(item continuity.AttentionItem) string {
|
||||
if item.State == continuity.AttentionDelivered {
|
||||
return "уведомление отправлено"
|
||||
}
|
||||
if item.State == continuity.AttentionDelivering {
|
||||
return "уведомление отправляется"
|
||||
}
|
||||
return "уведомление ещё не отправлено"
|
||||
}
|
||||
|
||||
func humanAttentionSource(kind continuity.AttentionSourceKind) string {
|
||||
if kind == continuity.AttentionSourceProspectiveReminder {
|
||||
return "напоминание"
|
||||
}
|
||||
return "событие Домового"
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
commitcontract "git.cin.su/m/Assistant/contracts/commit"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
type dueReminderRepository interface {
|
||||
DueArmed(context.Context, int64, int) ([]continuity.ProspectiveItem, error)
|
||||
ClaimDueReminder(context.Context, continuity.ProspectiveItemID, continuity.ProspectiveRevision, continuity.AttentionItemID, int64) (continuity.ProspectiveItem, continuity.AttentionItem, error)
|
||||
PendingAttention(context.Context, int) ([]continuity.AttentionItem, error)
|
||||
MarkAttentionDelivering(context.Context, continuity.AttentionItemID, continuity.AttentionRevision) (continuity.AttentionItem, error)
|
||||
MarkAttentionDelivered(context.Context, continuity.AttentionItemID, continuity.AttentionRevision, continuity.CommittedActionIntentID, continuity.CommittedAuthorizedActionID, continuity.CommittedActionAttemptID, string, int64) (continuity.AttentionItem, error)
|
||||
MarkAttentionFailed(context.Context, continuity.AttentionItemID, continuity.AttentionRevision, continuity.CommittedActionIntentID, continuity.CommittedAuthorizedActionID, continuity.CommittedActionAttemptID, string) (continuity.AttentionItem, error)
|
||||
Load(context.Context, continuity.ProspectiveItemID) (continuity.ProspectiveItem, error)
|
||||
}
|
||||
|
||||
type dueNotificationClient struct {
|
||||
baseURL *url.URL
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type dueNotifier interface {
|
||||
NotifyDue(context.Context, continuity.ProspectiveItem) (dueNotificationResponse, error)
|
||||
}
|
||||
|
||||
type dueNotificationRequest struct {
|
||||
ProspectiveItemID continuity.ProspectiveItemID `json:"prospective_item_id"`
|
||||
ExpectedRevision continuity.ProspectiveRevision `json:"expected_revision"`
|
||||
}
|
||||
|
||||
type dueNotificationResponse struct {
|
||||
AuthorizedActionID commitcontract.AuthorizedActionID `json:"AuthorizedActionID"`
|
||||
ActionIntentID commitcontract.ActionIntentID `json:"ActionIntentID"`
|
||||
ActionAttemptID commitcontract.ActionAttemptID `json:"ActionAttemptID"`
|
||||
State commitcontract.ActionState `json:"State"`
|
||||
}
|
||||
|
||||
func newDueNotificationClient(rawURL, token string, client *http.Client) (dueNotificationClient, error) {
|
||||
base, err := url.Parse(rawURL)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" || token == "" {
|
||||
return dueNotificationClient{}, fmt.Errorf("invalid Commit due notification configuration")
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return dueNotificationClient{baseURL: base, token: token, client: client}, nil
|
||||
}
|
||||
|
||||
func (c dueNotificationClient) NotifyDue(ctx context.Context, item continuity.ProspectiveItem) (dueNotificationResponse, error) {
|
||||
target := *c.baseURL
|
||||
target.Path = "/v1/internal/continuity/notify-due"
|
||||
payload, err := json.Marshal(dueNotificationRequest{ProspectiveItemID: item.ID, ExpectedRevision: item.Revision})
|
||||
if err != nil {
|
||||
return dueNotificationResponse{}, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return dueNotificationResponse{}, err
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+c.token)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := c.client.Do(request)
|
||||
if err != nil {
|
||||
return dueNotificationResponse{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusAccepted {
|
||||
return dueNotificationResponse{}, fmt.Errorf("commit due notification status %d", response.StatusCode)
|
||||
}
|
||||
var result dueNotificationResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return dueNotificationResponse{}, err
|
||||
}
|
||||
if result.ActionIntentID == "" || result.AuthorizedActionID == "" || result.ActionAttemptID == "" {
|
||||
return dueNotificationResponse{}, fmt.Errorf("invalid commit due notification response")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type dueReminderWorker struct {
|
||||
repository dueReminderRepository
|
||||
commit dueNotifier
|
||||
now func() time.Time
|
||||
newID func() (ids.ID, error)
|
||||
interval time.Duration
|
||||
limit int
|
||||
}
|
||||
|
||||
func (w dueReminderWorker) Start(ctx context.Context) {
|
||||
if w.interval <= 0 {
|
||||
w.interval = 15 * time.Second
|
||||
}
|
||||
if w.limit <= 0 {
|
||||
w.limit = 10
|
||||
}
|
||||
go func() {
|
||||
w.process(ctx)
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.process(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (w dueReminderWorker) process(ctx context.Context) {
|
||||
now := w.now()
|
||||
if err := w.claimDue(ctx, now); err != nil {
|
||||
log.Printf("due reminder claim skipped: %v", err)
|
||||
}
|
||||
if err := w.deliverPending(ctx, now); err != nil {
|
||||
log.Printf("due reminder delivery skipped: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (w dueReminderWorker) claimDue(ctx context.Context, now time.Time) error {
|
||||
items, err := w.repository.DueArmed(ctx, now.UnixMilli(), w.limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
id, err := w.newID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err = w.repository.ClaimDueReminder(ctx, item.ID, item.Revision, continuity.AttentionItemID(id), now.UnixMilli()); err != nil {
|
||||
log.Printf("due reminder %s not claimed: %v", item.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w dueReminderWorker) deliverPending(ctx context.Context, now time.Time) error {
|
||||
items, err := w.repository.PendingAttention(ctx, w.limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, attention := range items {
|
||||
if attention.SourceKind != continuity.AttentionSourceProspectiveReminder || attention.SourceProspectiveID == "" {
|
||||
continue
|
||||
}
|
||||
delivering, err := w.repository.MarkAttentionDelivering(ctx, attention.ID, attention.Revision)
|
||||
if err != nil {
|
||||
log.Printf("attention %s not marked delivering: %v", attention.ID, err)
|
||||
continue
|
||||
}
|
||||
prospective, err := w.repository.Load(ctx, delivering.SourceProspectiveID)
|
||||
if err != nil {
|
||||
log.Printf("attention %s source unavailable: %v", attention.ID, err)
|
||||
continue
|
||||
}
|
||||
result, err := w.commit.NotifyDue(ctx, prospective)
|
||||
if err != nil {
|
||||
log.Printf("attention %s notification unavailable: %v", attention.ID, err)
|
||||
continue
|
||||
}
|
||||
if result.State != commitcontract.ActionSucceeded {
|
||||
_, err = w.repository.MarkAttentionFailed(ctx, delivering.ID, delivering.Revision,
|
||||
continuity.CommittedActionIntentID(result.ActionIntentID),
|
||||
continuity.CommittedAuthorizedActionID(result.AuthorizedActionID),
|
||||
continuity.CommittedActionAttemptID(result.ActionAttemptID),
|
||||
string(result.State))
|
||||
if err != nil {
|
||||
log.Printf("attention %s delivery failure outcome not persisted: %v", attention.ID, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
_, err = w.repository.MarkAttentionDelivered(ctx, delivering.ID, delivering.Revision,
|
||||
continuity.CommittedActionIntentID(result.ActionIntentID),
|
||||
continuity.CommittedAuthorizedActionID(result.AuthorizedActionID),
|
||||
continuity.CommittedActionAttemptID(result.ActionAttemptID),
|
||||
string(result.State), now.UnixMilli())
|
||||
if err != nil {
|
||||
log.Printf("attention %s delivery outcome not persisted: %v", attention.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
commitcontract "git.cin.su/m/Assistant/contracts/commit"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
type memoryDueRepo struct {
|
||||
item continuity.ProspectiveItem
|
||||
attention continuity.AttentionItem
|
||||
}
|
||||
|
||||
func (r *memoryDueRepo) DueArmed(context.Context, int64, int) ([]continuity.ProspectiveItem, error) {
|
||||
if r.item.State == continuity.ProspectiveArmed {
|
||||
return []continuity.ProspectiveItem{r.item}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *memoryDueRepo) ClaimDueReminder(_ context.Context, id continuity.ProspectiveItemID, revision continuity.ProspectiveRevision, attentionID continuity.AttentionItemID, now int64) (continuity.ProspectiveItem, continuity.AttentionItem, error) {
|
||||
if r.item.ID != id || r.item.Revision != revision || r.item.State != continuity.ProspectiveArmed {
|
||||
return continuity.ProspectiveItem{}, continuity.AttentionItem{}, nil
|
||||
}
|
||||
r.item.State = continuity.ProspectiveDelivered
|
||||
r.item.Revision += 3
|
||||
r.attention = continuity.AttentionItem{ID: attentionID, OwnerPrincipalID: r.item.OwnerPrincipalID, ScopeID: r.item.ScopeID, SourceKind: continuity.AttentionSourceProspectiveReminder, SourceProspectiveID: r.item.ID, Level: continuity.AttentionDue, State: continuity.AttentionPending, Summary: r.item.Summary, DueAtUnixMillis: r.item.TriggerAtUnixMillis, CreatedAtUnixMillis: now, Revision: 1}
|
||||
return r.item, r.attention, nil
|
||||
}
|
||||
|
||||
func (r *memoryDueRepo) PendingAttention(context.Context, int) ([]continuity.AttentionItem, error) {
|
||||
if r.attention.State == continuity.AttentionPending {
|
||||
return []continuity.AttentionItem{r.attention}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *memoryDueRepo) MarkAttentionDelivering(_ context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision) (continuity.AttentionItem, error) {
|
||||
if r.attention.ID == id && r.attention.Revision == revision {
|
||||
r.attention.State = continuity.AttentionDelivering
|
||||
r.attention.Revision++
|
||||
}
|
||||
return r.attention, nil
|
||||
}
|
||||
|
||||
func (r *memoryDueRepo) MarkAttentionDelivered(_ context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision, intentID continuity.CommittedActionIntentID, authorizedID continuity.CommittedAuthorizedActionID, attemptID continuity.CommittedActionAttemptID, outcome string, deliveredAt int64) (continuity.AttentionItem, error) {
|
||||
if r.attention.ID == id && r.attention.Revision == revision {
|
||||
r.attention.State = continuity.AttentionDelivered
|
||||
r.attention.Revision++
|
||||
r.attention.ActionIntentID = intentID
|
||||
r.attention.AuthorizedActionID = authorizedID
|
||||
r.attention.ActionAttemptID = attemptID
|
||||
r.attention.DeliveryOutcome = outcome
|
||||
r.attention.DeliveredAtUnixMillis = deliveredAt
|
||||
}
|
||||
return r.attention, nil
|
||||
}
|
||||
|
||||
func (r *memoryDueRepo) MarkAttentionFailed(_ context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision, intentID continuity.CommittedActionIntentID, authorizedID continuity.CommittedAuthorizedActionID, attemptID continuity.CommittedActionAttemptID, outcome string) (continuity.AttentionItem, error) {
|
||||
if r.attention.ID == id && r.attention.Revision == revision {
|
||||
r.attention.State = continuity.AttentionFailed
|
||||
r.attention.Revision++
|
||||
r.attention.ActionIntentID = intentID
|
||||
r.attention.AuthorizedActionID = authorizedID
|
||||
r.attention.ActionAttemptID = attemptID
|
||||
r.attention.DeliveryOutcome = outcome
|
||||
}
|
||||
return r.attention, nil
|
||||
}
|
||||
|
||||
func (r *memoryDueRepo) Load(context.Context, continuity.ProspectiveItemID) (continuity.ProspectiveItem, error) {
|
||||
return r.item, nil
|
||||
}
|
||||
|
||||
type fakeDueNotifier struct{ calls int }
|
||||
|
||||
func (n *fakeDueNotifier) NotifyDue(context.Context, continuity.ProspectiveItem) (dueNotificationResponse, error) {
|
||||
n.calls++
|
||||
return dueNotificationResponse{ActionIntentID: "intent", AuthorizedActionID: "authorized", ActionAttemptID: "attempt", State: commitcontract.ActionSucceeded}, nil
|
||||
}
|
||||
|
||||
type unknownDueNotifier struct{ calls int }
|
||||
|
||||
func (n *unknownDueNotifier) NotifyDue(context.Context, continuity.ProspectiveItem) (dueNotificationResponse, error) {
|
||||
n.calls++
|
||||
return dueNotificationResponse{ActionIntentID: "intent", AuthorizedActionID: "authorized", ActionAttemptID: "attempt", State: commitcontract.ActionUnknownOutcome}, nil
|
||||
}
|
||||
|
||||
func TestDueReminderProducesAttentionAndNotificationOnce(t *testing.T) {
|
||||
repo := &memoryDueRepo{item: continuity.ProspectiveItem{ID: "prospective", OwnerPrincipalID: identity.PrincipalID("owner"), ScopeID: authority.ScopeID("scope"), Summary: "проверить уведомление", TriggerAtUnixMillis: 10, State: continuity.ProspectiveArmed, Revision: 1}}
|
||||
notifier := &fakeDueNotifier{}
|
||||
now := time.UnixMilli(20)
|
||||
worker := dueReminderWorker{repository: repo, commit: notifier, now: func() time.Time { return now }, newID: func() (ids.ID, error) { return ids.ID("attention"), nil }, limit: 10}
|
||||
worker.process(context.Background())
|
||||
worker.process(context.Background())
|
||||
if repo.item.State != continuity.ProspectiveDelivered || repo.attention.State != continuity.AttentionDelivered {
|
||||
t.Fatalf("unexpected states: prospective=%s attention=%s", repo.item.State, repo.attention.State)
|
||||
}
|
||||
if notifier.calls != 1 {
|
||||
t.Fatalf("expected one notification call, got %d", notifier.calls)
|
||||
}
|
||||
if repo.attention.ActionIntentID == "" || repo.attention.DeliveredAtUnixMillis == 0 {
|
||||
t.Fatal("delivery evidence was not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDueReminderUnknownOutcomeDoesNotMarkAttentionDelivered(t *testing.T) {
|
||||
repo := &memoryDueRepo{item: continuity.ProspectiveItem{ID: "prospective", OwnerPrincipalID: identity.PrincipalID("owner"), ScopeID: authority.ScopeID("scope"), Summary: "проверить уведомление", TriggerAtUnixMillis: 10, State: continuity.ProspectiveArmed, Revision: 1}}
|
||||
notifier := &unknownDueNotifier{}
|
||||
now := time.UnixMilli(20)
|
||||
worker := dueReminderWorker{repository: repo, commit: notifier, now: func() time.Time { return now }, newID: func() (ids.ID, error) { return ids.ID("attention"), nil }, limit: 10}
|
||||
worker.process(context.Background())
|
||||
if repo.attention.State != continuity.AttentionFailed {
|
||||
t.Fatalf("unknown outcome must not be delivered, got %s", repo.attention.State)
|
||||
}
|
||||
if repo.attention.DeliveryOutcome != string(commitcontract.ActionUnknownOutcome) {
|
||||
t.Fatalf("missing unknown outcome evidence: %#v", repo.attention)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
continuitydomain "git.cin.su/m/Assistant/modules/continuity/domain"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
type responseRepository interface {
|
||||
ResponseCandidates(context.Context, identity.PrincipalID, int) ([]continuitydomain.ResponseCandidate, error)
|
||||
ApplyHumanResponse(context.Context, continuity.HumanResponse) error
|
||||
}
|
||||
|
||||
func registerCreatorResponseRoute(mux *http.ServeMux, identityClient creatorIdentityClient, repository responseRepository, now func() time.Time) {
|
||||
mux.HandleFunc("POST /v1/creator/responses", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := identityClient.Session(r.Context(), r.Header.Get("Authorization"))
|
||||
if errors.Is(err, errCreatorSessionDenied) {
|
||||
http.Error(w, "creator session denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "identity service unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var request continuity.HumanResponseRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, "invalid human response", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
responseID, err := ids.New()
|
||||
if err != nil {
|
||||
http.Error(w, "human response unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
candidates, err := repository.ResponseCandidates(r.Context(), session.PrincipalID, 3)
|
||||
if err != nil {
|
||||
http.Error(w, "response candidates unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
result, err := continuitydomain.InterpretHumanResponse(request, session.PrincipalID, authority.ScopeID("domovoy-global-scope"), continuity.HumanResponseID(responseID), candidates, now())
|
||||
if err != nil {
|
||||
http.Error(w, "invalid human response", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if result.Handled {
|
||||
if err := repository.ApplyHumanResponse(r.Context(), result.Response); err != nil {
|
||||
http.Error(w, "apply human response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
continuitydomain "git.cin.su/m/Assistant/modules/continuity/domain"
|
||||
)
|
||||
|
||||
func TestCreatorResponseEndpointAppliesCompleteThroughContinuitySemantics(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/creator/sessions/current" || r.Header.Get("Authorization") != "Bearer ok" {
|
||||
t.Fatal("unexpected identity validation")
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor-1", PrincipalID: "principal-1"})
|
||||
}))
|
||||
defer core.Close()
|
||||
identityClient, err := newCreatorIdentityClient(core.URL, core.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repository := &fakeResponseRepository{candidates: []continuitydomain.ResponseCandidate{{
|
||||
Attention: continuity.AttentionItem{ID: "attention-1", OwnerPrincipalID: "principal-1", ScopeID: "scope-1", SourceKind: continuity.AttentionSourceProspectiveReminder, SourceProspectiveID: "prospective-1", State: continuity.AttentionDelivered, Summary: "проверить базу", Revision: 1},
|
||||
Prospective: continuity.ProspectiveItem{ID: "prospective-1", OwnerPrincipalID: "principal-1", ScopeID: "scope-1", State: continuity.ProspectiveDelivered, Summary: "проверить базу", Revision: 1},
|
||||
}}}
|
||||
mux := http.NewServeMux()
|
||||
registerCreatorResponseRoute(mux, identityClient, repository, func() time.Time { return time.Unix(100, 0) })
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/creator/responses", strings.NewReader(`{"SourceUtteranceID":"utterance-1","SourceIntentID":"intent-1","Message":"Готово","RequestedAtUnixMillis":100000}`))
|
||||
request.Header.Set("Authorization", "Bearer ok")
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusCreated || repository.applied.Operation != continuity.ResponseComplete || repository.applied.Outcome != continuity.ResponseOutcomeUserReportedComplete {
|
||||
t.Fatalf("status=%d applied=%#v body=%s", recorder.Code, repository.applied, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorResponseEndpointRecordsAmbiguousWithoutSilentTargetMutation(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor-1", PrincipalID: "principal-1"})
|
||||
}))
|
||||
defer core.Close()
|
||||
identityClient, err := newCreatorIdentityClient(core.URL, core.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repository := &fakeResponseRepository{candidates: []continuitydomain.ResponseCandidate{
|
||||
{Attention: continuity.AttentionItem{ID: "attention-1", SourceProspectiveID: "prospective-1", State: continuity.AttentionDelivered, Summary: "первое"}},
|
||||
{Attention: continuity.AttentionItem{ID: "attention-2", SourceProspectiveID: "prospective-2", State: continuity.AttentionDelivered, Summary: "второе"}},
|
||||
}}
|
||||
mux := http.NewServeMux()
|
||||
registerCreatorResponseRoute(mux, identityClient, repository, func() time.Time { return time.Unix(100, 0) })
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/creator/responses", strings.NewReader(`{"SourceUtteranceID":"utterance-1","SourceIntentID":"intent-1","Message":"Готово","RequestedAtUnixMillis":100000}`))
|
||||
request.Header.Set("Authorization", "Bearer ok")
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusCreated || repository.applied.ResolutionState != continuity.ResponseAmbiguous || repository.applied.TargetAttentionID != "" {
|
||||
t.Fatalf("status=%d applied=%#v body=%s", recorder.Code, repository.applied, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type fakeResponseRepository struct {
|
||||
candidates []continuitydomain.ResponseCandidate
|
||||
applied continuity.HumanResponse
|
||||
}
|
||||
|
||||
func (f *fakeResponseRepository) ResponseCandidates(context.Context, identity.PrincipalID, int) ([]continuitydomain.ResponseCandidate, error) {
|
||||
return f.candidates, nil
|
||||
}
|
||||
|
||||
func (f *fakeResponseRepository) ApplyHumanResponse(_ context.Context, response continuity.HumanResponse) error {
|
||||
f.applied = response
|
||||
return nil
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
)
|
||||
|
||||
@@ -34,7 +35,15 @@ func main() {
|
||||
if executorToken == "" {
|
||||
log.Fatal("COMMIT_EXECUTOR_TOKEN is required")
|
||||
}
|
||||
commitClient, err := newDueNotificationClient(envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"), executorToken, &http.Client{Timeout: 10 * time.Second})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
workerContext, cancelWorker := context.WithCancel(context.Background())
|
||||
defer cancelWorker()
|
||||
dueReminderWorker{repository: repository, commit: commitClient, now: time.Now, newID: ids.New, interval: 5 * time.Second, limit: 10}.Start(workerContext)
|
||||
registerInternalExecutorRoutes(mux, repository, executorToken)
|
||||
registerCreatorResponseRoute(mux, identityClient, repository, time.Now)
|
||||
mux.HandleFunc("POST /v1/prospective-items", func(w http.ResponseWriter, r *http.Request) {
|
||||
var item continuity.ProspectiveItem
|
||||
if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
|
||||
@@ -47,6 +56,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 {
|
||||
@@ -103,6 +113,7 @@ func main() {
|
||||
Items []continuity.ProspectiveItem
|
||||
}{Items: items})
|
||||
})
|
||||
registerCreatorAttentionRoutes(mux, identityClient, repository)
|
||||
log.Printf("continuity-runtime listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
@@ -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})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
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
|
||||
}
|
||||
|
||||
type continuityResponseClient struct {
|
||||
baseURL *url.URL
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newContinuityReminderClient(rawURL string, client *http.Client) (continuityReminderClient, error) {
|
||||
base, err := url.Parse(rawURL)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
return continuityReminderClient{}, fmt.Errorf("invalid Continuity Runtime URL")
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return continuityReminderClient{baseURL: base, client: client}, nil
|
||||
}
|
||||
|
||||
func newContinuityResponseClient(rawURL string, client *http.Client) (continuityResponseClient, error) {
|
||||
base, err := url.Parse(rawURL)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
return continuityResponseClient{}, fmt.Errorf("invalid Continuity Runtime URL")
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return continuityResponseClient{baseURL: base, client: client}, nil
|
||||
}
|
||||
|
||||
func (c continuityReminderClient) Schedule(ctx context.Context, authorization string, request continuity.ScheduleReminderRequest) (continuity.ScheduleReminderResult, error) {
|
||||
target := *c.baseURL
|
||||
target.Path = "/v1/creator/reminders"
|
||||
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 (c continuityResponseClient) Respond(ctx context.Context, authorization string, request continuity.HumanResponseRequest) (continuity.HumanResponseResult, error) {
|
||||
target := *c.baseURL
|
||||
target.Path = "/v1/creator/responses"
|
||||
payload, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return continuity.HumanResponseResult{}, err
|
||||
}
|
||||
upstream, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return continuity.HumanResponseResult{}, err
|
||||
}
|
||||
upstream.Header.Set("Content-Type", "application/json")
|
||||
upstream.Header.Set("Authorization", authorization)
|
||||
response, err := c.client.Do(upstream)
|
||||
if err != nil {
|
||||
return continuity.HumanResponseResult{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusCreated {
|
||||
return continuity.HumanResponseResult{}, fmt.Errorf("continuity response status %d", response.StatusCode)
|
||||
}
|
||||
var result continuity.HumanResponseResult
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return continuity.HumanResponseResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func resolveChatCapability(ctx context.Context, repository *postgres.CapabilityRepository, kind capability.CapabilityKind, session identity.CreatorSession, intent interaction.OriginalHumanIntent, utterance interaction.HumanUtterance, now time.Time) (capability.CapabilityResolution, error) {
|
||||
requirementID, err := ids.New()
|
||||
if err != nil {
|
||||
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)
|
||||
relative := regexp.MustCompile(`через\s+(\d{1,3})\s*(минуту|минуты|минут|час|часа|часов)`)
|
||||
if match := relative.FindStringSubmatch(lower); len(match) > 0 {
|
||||
value, err := strconv.Atoi(match[1])
|
||||
if err != nil || value < 1 {
|
||||
return time.Time{}, fmt.Errorf("invalid relative reminder time")
|
||||
}
|
||||
unit := match[2]
|
||||
if strings.HasPrefix(unit, "час") {
|
||||
if value > 72 {
|
||||
return time.Time{}, fmt.Errorf("relative reminder time is too far")
|
||||
}
|
||||
return now.Add(time.Duration(value) * time.Hour), nil
|
||||
}
|
||||
if value > 24*60 {
|
||||
return time.Time{}, fmt.Errorf("relative reminder time is too far")
|
||||
}
|
||||
return now.Add(time.Duration(value) * time.Minute), nil
|
||||
}
|
||||
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, ""))
|
||||
}
|
||||
relative := regexp.MustCompile(`(?i)через\s+\d{1,3}\s*(минуту|минуты|минут|час|часа|часов)`)
|
||||
clean = strings.TrimSpace(relative.ReplaceAllString(clean, ""))
|
||||
re := regexp.MustCompile(`(?i)(в|на)\s+[0-2]?\d(?::[0-5]\d)?`)
|
||||
clean = strings.TrimSpace(re.ReplaceAllString(clean, ""))
|
||||
clean = strings.Trim(clean, " .,!?:;")
|
||||
return clean
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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 TestBuildReminderRequestParsesRelativeMinutes(t *testing.T) {
|
||||
now := time.Date(2026, 8, 17, 12, 0, 0, 0, time.FixedZone("MSK", 3*60*60))
|
||||
request, err := buildReminderRequest("Напомни мне через 3 минуты проверить уведомление.", 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.Equal(now.Add(3 * time.Minute)) {
|
||||
t.Fatalf("unexpected trigger: %s", trigger)
|
||||
}
|
||||
if request.Summary != "проверить уведомление" {
|
||||
t.Fatalf("unexpected summary: %q", request.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
+109
-2
@@ -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,14 @@ 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)
|
||||
}
|
||||
responseClient, err := newContinuityResponseClient(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), &http.Client{Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
authenticateCreator := func(w http.ResponseWriter, r *http.Request) (identity.CreatorSession, bool) {
|
||||
session, err := identitydomain.ValidateCreatorSession(sessionKey, bearerToken(r), time.Now())
|
||||
@@ -230,6 +239,12 @@ func main() {
|
||||
http.Error(w, "save chat semantics", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
responseResult, responseErr := responseClient.Respond(r.Context(), r.Header.Get("Authorization"), continuity.HumanResponseRequest{
|
||||
SourceUtteranceID: string(utterance.ID),
|
||||
SourceIntentID: string(intent.ID),
|
||||
Message: utterance.Content,
|
||||
RequestedAtUnixMillis: now.UnixMilli(),
|
||||
})
|
||||
activeFocusName := ""
|
||||
activeFocusProvenance := situation.ProjectResolutionProvenance("")
|
||||
if semantic.ActiveFocus != nil {
|
||||
@@ -239,7 +254,7 @@ func main() {
|
||||
}
|
||||
activeFocusName = semantic.ActiveFocus.ProjectDisplayName
|
||||
activeFocusProvenance = semantic.ActiveFocus.Provenance
|
||||
} else if previousFocus != nil {
|
||||
} else if len(semantic.Projects) > 0 && semantic.Projects[0].Provenance == situation.ProjectResolutionInheritedFocus && previousFocus != nil {
|
||||
activeFocusName = previousFocus.ProjectDisplayName
|
||||
activeFocusProvenance = previousFocus.Provenance
|
||||
}
|
||||
@@ -249,9 +264,13 @@ func main() {
|
||||
projectID := ""
|
||||
if semantic.ActiveFocus != nil {
|
||||
projectID = string(semantic.ActiveFocus.ProjectID)
|
||||
} else if previousFocus != nil {
|
||||
} else if len(semantic.Projects) > 0 && semantic.Projects[0].Provenance == situation.ProjectResolutionInheritedFocus {
|
||||
projectID = string(previousFocus.ProjectID)
|
||||
}
|
||||
if responseErr == nil && responseResult.Handled {
|
||||
planningReply = responseResult.Reply
|
||||
needsCognition = false
|
||||
}
|
||||
planningInput := work.PlanningInput{
|
||||
ConversationID: work.ConversationRef(conversation.ID),
|
||||
UtteranceID: work.SourceUtteranceRef(utterance.ID),
|
||||
@@ -268,10 +287,66 @@ func main() {
|
||||
ReceivedAtUnixMillis: utterance.ReceivedAtUnixMillis,
|
||||
}
|
||||
helpPath := workdomain.DetermineHelpPath(planningInput)
|
||||
if responseErr == nil && responseResult.Handled {
|
||||
helpPath = work.HelpPath{}
|
||||
}
|
||||
if isCapabilityQuestion(utterance.Content) {
|
||||
kinds, err := capabilityRepository.AvailableCapabilityKinds(r.Context(), session.PrincipalID)
|
||||
if err == nil && len(kinds) > 0 {
|
||||
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 +364,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 +374,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 +769,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, ", ")
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
)
|
||||
@@ -93,3 +94,78 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatAttentionQuestionUsesContinuityToday(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.SpeechActQuestion, FallbackText: "Сейчас в фокусе: УПО."})
|
||||
default:
|
||||
t.Fatalf("unexpected core route %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer core.Close()
|
||||
continuityRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/v1/creator/today" {
|
||||
t.Fatalf("unexpected continuity route %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(continuity.TodayResult{Items: []continuity.TodayItem{{Title: "проверить уведомление", HumanState: "доставлено", DeliveryState: "уведомление отправлено"}}})
|
||||
}))
|
||||
defer continuityRuntime.Close()
|
||||
handler, err := newHandler(core.URL, continuityRuntime.URL, "http://commit", core.Client(), "http://cognitive")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/chat/messages", strings.NewReader(`{"message":"Что сейчас требует моего внимания?"}`))
|
||||
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 !strings.Contains(response.ReplyText, "проверить уведомление") || strings.Contains(response.ReplyText, "УПО") {
|
||||
t.Fatalf("attention reply did not use canonical today: %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
"git.cin.su/m/Assistant/contracts/situation"
|
||||
@@ -151,6 +152,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) {
|
||||
@@ -320,6 +324,15 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) {
|
||||
if saved.PlanningReply != "" {
|
||||
reply = saved.PlanningReply
|
||||
}
|
||||
if asksAttention(browserRequest.Message) {
|
||||
var today continuity.TodayResult
|
||||
status, err = g.getTrustedJSON(r, g.continuity, "/v1/creator/today", "", authorization, &today)
|
||||
if err == nil && status == http.StatusOK {
|
||||
reply = renderTodayReply(today)
|
||||
cognition = "not_requested"
|
||||
saved.NeedsCognition = false
|
||||
}
|
||||
}
|
||||
if saved.NeedsCognition && !saved.WorkPlanned && saved.Clarification != interaction.ClarificationRequired {
|
||||
cognition = "unavailable"
|
||||
requestID, idErr := ids.New()
|
||||
@@ -340,7 +353,46 @@ 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 asksAttention(message string) bool {
|
||||
lower := strings.ToLower(message)
|
||||
return strings.Contains(lower, "требует моего внимания") || strings.Contains(lower, "требует внимания") || strings.Contains(lower, "что сейчас важно")
|
||||
}
|
||||
|
||||
func renderTodayReply(today continuity.TodayResult) string {
|
||||
if len(today.Items) == 0 {
|
||||
return "Сейчас ничего срочного не требует внимания."
|
||||
}
|
||||
limit := len(today.Items)
|
||||
if limit > 5 {
|
||||
limit = 5
|
||||
}
|
||||
var builder strings.Builder
|
||||
builder.WriteString("Сейчас требует внимания:\n")
|
||||
for index := 0; index < limit; index++ {
|
||||
item := today.Items[index]
|
||||
title := strings.TrimSpace(item.Title)
|
||||
if title == "" {
|
||||
title = "событие Домового"
|
||||
}
|
||||
state := strings.TrimSpace(item.HumanState)
|
||||
if state == "" {
|
||||
state = "ожидает внимания"
|
||||
}
|
||||
builder.WriteString("- ")
|
||||
builder.WriteString(title)
|
||||
builder.WriteString(" — ")
|
||||
builder.WriteString(state)
|
||||
if item.DeliveryState != "" {
|
||||
builder.WriteString(" (")
|
||||
builder.WriteString(item.DeliveryState)
|
||||
builder.WriteString(")")
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
|
||||
func validAdvisoryResponse(response cognitive.GatewayAdvisoryResponse, request cognitive.GatewayAdvisoryRequest) bool {
|
||||
@@ -491,6 +543,10 @@ func creatorRoute(method, path string) (routeTarget, string, bool) {
|
||||
}
|
||||
case method == http.MethodGet && path == "/api/prospective-items":
|
||||
return targetContinuity, "/v1/creator/prospective-items", true
|
||||
case method == http.MethodGet && path == "/api/attention":
|
||||
return targetContinuity, "/v1/creator/attention", true
|
||||
case method == http.MethodGet && path == "/api/today":
|
||||
return targetContinuity, "/v1/creator/today", true
|
||||
case method == http.MethodGet && path == "/api/actions":
|
||||
return targetCommit, "/v1/creator/authorized-actions", true
|
||||
case method == http.MethodPost && path == "/api/actions/satisfy-delivered":
|
||||
|
||||
@@ -29,12 +29,18 @@ textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px;
|
||||
.login-form button, .composer button { border: 0; color: #11150f; background: var(--signal); font-weight: 700; }
|
||||
.login-form button { width: 100%; height: 50px; margin-top: 18px; }
|
||||
#login-error { min-height: 20px; color: var(--bad); font-size: 13px; }
|
||||
.chat-view { min-height: 100vh; display: grid; grid-template-rows: 64px minmax(0, 1fr) auto; }
|
||||
.chat-view { min-height: 100vh; display: grid; grid-template-rows: 64px minmax(0, 1fr); }
|
||||
.chat-header { display: flex; align-items: center; gap: 18px; padding: 0 22px; border-bottom: 1px solid var(--line); background: rgba(16, 23, 20, .96); }
|
||||
.chat-header a { color: var(--ink); text-decoration: none; font: 400 24px/1 var(--serif); }
|
||||
.chat-header span { color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
|
||||
#active-focus { margin-left: auto; color: var(--good); }
|
||||
.chat-header button { width: 38px; height: 38px; border: 1px solid var(--line); background: transparent; }
|
||||
.human-nav { display: flex; align-items: center; gap: 8px; }
|
||||
.nav-tab, .operator-link { height: 34px; padding: 0 12px; display: inline-flex; align-items: center; border: 1px solid var(--line); color: var(--muted); background: #0b110e; text-decoration: none; font: 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
|
||||
.nav-tab.active { color: #11150f; border-color: var(--signal); background: var(--signal); }
|
||||
.operator-link { color: var(--good); }
|
||||
.surface-panel { min-height: 0; position: relative; }
|
||||
.active-panel { display: grid; min-height: 0; }
|
||||
.messages { width: min(100%, 920px); margin: 0 auto; padding: 28px 18px 120px; display: flex; flex-direction: column; gap: 14px; overflow: auto; }
|
||||
.message { max-width: min(720px, 92%); padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius); line-height: 1.45; white-space: pre-wrap; }
|
||||
.message.user { align-self: flex-end; background: #151e1a; }
|
||||
@@ -43,8 +49,28 @@ textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px;
|
||||
.composer { position: sticky; bottom: 0; width: min(100%, 920px); margin: 0 auto; padding: 16px 18px 20px; display: grid; grid-template-columns: minmax(0, 1fr) 130px; gap: 12px; background: linear-gradient(transparent, var(--base) 20%); }
|
||||
.composer button { height: 48px; align-self: end; }
|
||||
.composer button:disabled { cursor: wait; filter: saturate(.3); }
|
||||
.attention-surface, .history-surface { width: min(100%, 920px); margin: 0 auto; padding: 34px 18px; align-content: start; gap: 18px; }
|
||||
.surface-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); padding-bottom: 16px; }
|
||||
.surface-heading p { margin: 0; font: 400 36px/.95 var(--serif); }
|
||||
.surface-heading span, .attention-card small, .history-item span { color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .1em; }
|
||||
.surface-heading button { height: 36px; padding: 0 12px; border: 1px solid var(--line); background: transparent; }
|
||||
.attention-list, .history-list { display: grid; gap: 12px; }
|
||||
.attention-card, .history-item { border: 1px solid var(--line); background: #0f1613; padding: 16px; }
|
||||
.attention-card time { color: var(--signal); font: 12px/1 var(--mono); }
|
||||
.attention-card h2 { margin: 10px 0 8px; font: 400 24px/1.1 var(--serif); }
|
||||
.attention-card p, .history-item p { margin: 0 0 10px; color: var(--ink); line-height: 1.45; }
|
||||
.attention-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
|
||||
.attention-actions button { height: 34px; padding: 0 12px; border: 1px solid var(--line); background: #151e1a; color: var(--ink); font: 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
|
||||
.attention-actions button:first-child { border-color: var(--good); color: var(--good); }
|
||||
.history-item.user { border-color: #34443c; background: #151e1a; }
|
||||
.history-item strong { display: block; margin-bottom: 8px; color: var(--signal); }
|
||||
.empty-state { margin: 24px 0 0; color: var(--muted); }
|
||||
.empty-state.error { color: var(--bad); }
|
||||
@media (max-width: 640px) {
|
||||
.composer { grid-template-columns: 1fr; }
|
||||
.composer button { width: 100%; }
|
||||
.chat-header { gap: 10px; padding: 0 12px; }
|
||||
.chat-header span { display: none; }
|
||||
.human-nav { overflow-x: auto; }
|
||||
.operator-link, .nav-tab { padding: 0 9px; }
|
||||
}
|
||||
|
||||
@@ -8,9 +8,14 @@ const messageInput = document.querySelector("#message");
|
||||
const sendButton = document.querySelector("#send");
|
||||
const logoutButton = document.querySelector("#logout");
|
||||
const activeFocus = document.querySelector("#active-focus");
|
||||
const todayList = document.querySelector("#today-list");
|
||||
const refreshToday = document.querySelector("#refresh-today");
|
||||
const historyList = document.querySelector("#history-list");
|
||||
const navTabs = Array.from(document.querySelectorAll(".nav-tab"));
|
||||
|
||||
let token = sessionStorage.getItem("domovoy.chat.token") || "";
|
||||
let conversationID = sessionStorage.getItem("domovoy.chat.conversation") || "";
|
||||
let localHistory = [];
|
||||
|
||||
function showChat() {
|
||||
loginView.hidden = true;
|
||||
@@ -37,11 +42,14 @@ function appendMessage(kind, text, meta = "") {
|
||||
}
|
||||
messages.appendChild(node);
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
localHistory.push({kind, text, meta, at: new Date()});
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
function updateActiveFocus(value, provenance = "") {
|
||||
if (!activeFocus) return;
|
||||
activeFocus.textContent = value ? `фокус: ${value}${provenance ? ` · ${provenance}` : ""}` : "фокус: не выбран";
|
||||
activeFocus.textContent = value ? `Сейчас обсуждаем: ${value}` : "";
|
||||
activeFocus.hidden = !value;
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
@@ -52,6 +60,134 @@ async function api(path, options = {}) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function humanMeta(result) {
|
||||
if (result.reminder_scheduled) return "Напоминание сохранено. Я вернусь к нему сам.";
|
||||
if (result.capability_status && result.capability_status !== "AVAILABLE") return "Возможность пока не подключена.";
|
||||
if (result.work_planned) return "Я разложил это как работу.";
|
||||
return "";
|
||||
}
|
||||
|
||||
function renderHistory() {
|
||||
if (!historyList) return;
|
||||
historyList.replaceChildren();
|
||||
const items = localHistory.slice(-30).reverse();
|
||||
if (!items.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "empty-state";
|
||||
empty.textContent = "История появится после сообщений в этом окне.";
|
||||
historyList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const row = document.createElement("article");
|
||||
row.className = `history-item ${item.kind}`;
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = item.kind === "user" ? "Вы" : "Домовой";
|
||||
const body = document.createElement("p");
|
||||
body.textContent = item.text;
|
||||
const time = document.createElement("span");
|
||||
time.textContent = item.at.toLocaleTimeString("ru-RU", {hour: "2-digit", minute: "2-digit"});
|
||||
row.append(title, body, time);
|
||||
historyList.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadToday() {
|
||||
if (!todayList || !token) return;
|
||||
todayList.replaceChildren();
|
||||
const loading = document.createElement("p");
|
||||
loading.className = "empty-state";
|
||||
loading.textContent = "Смотрю, что требует внимания...";
|
||||
todayList.appendChild(loading);
|
||||
try {
|
||||
const result = await api("/api/today", {method: "GET"});
|
||||
todayList.replaceChildren();
|
||||
const items = result.Items || result.items || [];
|
||||
if (!items.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "empty-state";
|
||||
empty.textContent = "Сейчас ничего срочного не требует внимания.";
|
||||
todayList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const card = document.createElement("article");
|
||||
card.className = "attention-card";
|
||||
const when = document.createElement("time");
|
||||
const stamp = item.WhenUnixMillis || item.when_unix_millis || 0;
|
||||
when.textContent = stamp ? new Date(stamp).toLocaleString("ru-RU", {day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit"}) : "сейчас";
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = item.Title || item.title || "Событие Домового";
|
||||
const state = document.createElement("p");
|
||||
state.textContent = item.HumanState || item.human_state || "ожидает внимания";
|
||||
const source = document.createElement("small");
|
||||
source.textContent = [item.SourceDescription || item.source_description, item.DeliveryState || item.delivery_state].filter(Boolean).join(" · ");
|
||||
const hintText = item.ResponseHint || item.response_hint || "";
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "attention-actions";
|
||||
for (const [label, text] of [["Готово", "Готово"], ["Отложить", "Отложи на 10 минут"], ["Отменить", "Отмени"]]) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = label;
|
||||
button.addEventListener("click", async () => {
|
||||
await sendChatMessage(text, {silentUser: false});
|
||||
await loadToday();
|
||||
activateTab("chat-panel");
|
||||
});
|
||||
actions.appendChild(button);
|
||||
}
|
||||
if (hintText) {
|
||||
const hint = document.createElement("small");
|
||||
hint.textContent = hintText;
|
||||
card.append(when, title, state, source, hint, actions);
|
||||
} else {
|
||||
card.append(when, title, state, source, actions);
|
||||
}
|
||||
todayList.appendChild(card);
|
||||
}
|
||||
} catch {
|
||||
todayList.replaceChildren();
|
||||
const error = document.createElement("p");
|
||||
error.className = "empty-state error";
|
||||
error.textContent = "Внимание сейчас недоступно. Чат можно продолжать.";
|
||||
todayList.appendChild(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendChatMessage(text, options = {}) {
|
||||
const clean = text.trim();
|
||||
if (!clean) return;
|
||||
if (!options.silentUser) appendMessage("user", clean);
|
||||
sendButton.disabled = true;
|
||||
try {
|
||||
const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: clean})});
|
||||
conversationID = result.conversation_id;
|
||||
sessionStorage.setItem("domovoy.chat.conversation", conversationID);
|
||||
updateActiveFocus(result.active_focus, result.project_provenance);
|
||||
appendMessage("assistant", result.reply_text || "Сообщение записано.", humanMeta(result));
|
||||
if (/что сейчас требует моего внимания/i.test(clean)) {
|
||||
await loadToday();
|
||||
activateTab("today-panel");
|
||||
}
|
||||
} catch {
|
||||
appendMessage("assistant", "Чат сейчас недоступен. Сообщение не принято runtime.");
|
||||
} finally {
|
||||
sendButton.disabled = false;
|
||||
messageInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function activateTab(panelID) {
|
||||
for (const tab of navTabs) tab.classList.toggle("active", tab.dataset.tab === panelID);
|
||||
for (const panel of document.querySelectorAll(".surface-panel")) {
|
||||
const active = panel.id === panelID;
|
||||
panel.hidden = !active;
|
||||
panel.classList.toggle("active-panel", active);
|
||||
}
|
||||
if (panelID === "today-panel") loadToday();
|
||||
if (panelID === "history-panel") renderHistory();
|
||||
}
|
||||
|
||||
loginForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
loginError.textContent = "";
|
||||
@@ -71,26 +207,16 @@ chatForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const text = messageInput.value.trim();
|
||||
if (!text) return;
|
||||
appendMessage("user", text);
|
||||
messageInput.value = "";
|
||||
sendButton.disabled = true;
|
||||
try {
|
||||
const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: text})});
|
||||
conversationID = result.conversation_id;
|
||||
sessionStorage.setItem("domovoy.chat.conversation", conversationID);
|
||||
updateActiveFocus(result.active_focus, result.project_provenance);
|
||||
const semanticMeta = [result.speech_act, `cognition=${result.cognition}`];
|
||||
if (result.work_planned) semanticMeta.push("work=planned");
|
||||
if (result.clarification) semanticMeta.push(`clarification=${result.clarification}`);
|
||||
appendMessage("assistant", result.reply_text || "Сообщение записано.", semanticMeta.join("; "));
|
||||
} catch {
|
||||
appendMessage("assistant", "Чат сейчас недоступен. Сообщение не принято runtime.");
|
||||
} finally {
|
||||
sendButton.disabled = false;
|
||||
messageInput.focus();
|
||||
}
|
||||
await sendChatMessage(text);
|
||||
});
|
||||
|
||||
for (const tab of navTabs) {
|
||||
tab.addEventListener("click", () => activateTab(tab.dataset.tab));
|
||||
}
|
||||
|
||||
if (refreshToday) refreshToday.addEventListener("click", loadToday);
|
||||
|
||||
logoutButton.addEventListener("click", () => {
|
||||
token = "";
|
||||
conversationID = "";
|
||||
|
||||
@@ -25,16 +25,38 @@
|
||||
|
||||
<section id="chat-view" class="chat-view" hidden>
|
||||
<header class="chat-header">
|
||||
<a href="/" aria-label="Консоль создателя">Домовой</a>
|
||||
<a href="/chat" aria-label="Домовой">Домовой</a>
|
||||
<nav class="human-nav" aria-label="Разделы Домового">
|
||||
<button class="nav-tab active" data-tab="chat-panel" type="button">Чат</button>
|
||||
<button class="nav-tab" data-tab="today-panel" type="button">Сегодня</button>
|
||||
<button class="nav-tab" data-tab="history-panel" type="button">История</button>
|
||||
<a href="/" class="operator-link">Оператор</a>
|
||||
</nav>
|
||||
<span id="active-focus">фокус: не выбран</span>
|
||||
<span id="session-state">сессия активна</span>
|
||||
<button id="logout" type="button" aria-label="Выйти">↗</button>
|
||||
</header>
|
||||
<div id="messages" class="messages" aria-live="polite"></div>
|
||||
<form id="chat-form" class="composer">
|
||||
<textarea id="message" rows="3" maxlength="16384" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
|
||||
<button id="send" type="submit">Отправить</button>
|
||||
</form>
|
||||
<section id="chat-panel" class="surface-panel active-panel">
|
||||
<div id="messages" class="messages" aria-live="polite"></div>
|
||||
<form id="chat-form" class="composer">
|
||||
<textarea id="message" rows="3" maxlength="16384" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
|
||||
<button id="send" type="submit">Отправить</button>
|
||||
</form>
|
||||
</section>
|
||||
<section id="today-panel" class="surface-panel attention-surface" hidden>
|
||||
<div class="surface-heading">
|
||||
<p>Сегодня / внимание</p>
|
||||
<button id="refresh-today" type="button">Обновить</button>
|
||||
</div>
|
||||
<div id="today-list" class="attention-list"></div>
|
||||
</section>
|
||||
<section id="history-panel" class="surface-panel history-surface" hidden>
|
||||
<div class="surface-heading">
|
||||
<p>История</p>
|
||||
<span>Последние сообщения текущего окна</span>
|
||||
</div>
|
||||
<div id="history-list" class="history-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/assets/chat.js"></script>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -9,9 +9,14 @@ import (
|
||||
|
||||
type CommitmentID ids.ID
|
||||
type ProspectiveItemID ids.ID
|
||||
type AttentionItemID ids.ID
|
||||
type ProspectiveRevision uint64
|
||||
type AttentionRevision uint64
|
||||
type CommittedActionIntentID ids.ID
|
||||
type CommittedAuthorizedActionID ids.ID
|
||||
type CommittedActionAttemptID ids.ID
|
||||
type HumanResponseID ids.ID
|
||||
type HumanResponseRevision uint64
|
||||
|
||||
type ProspectiveState string
|
||||
|
||||
@@ -26,6 +31,35 @@ const (
|
||||
ProspectiveExpired ProspectiveState = "EXPIRED"
|
||||
)
|
||||
|
||||
type AttentionLevel string
|
||||
|
||||
const (
|
||||
AttentionUrgent AttentionLevel = "URGENT"
|
||||
AttentionActionRequired AttentionLevel = "ACTION_REQUIRED"
|
||||
AttentionDue AttentionLevel = "DUE"
|
||||
AttentionImportantChange AttentionLevel = "IMPORTANT_CHANGE"
|
||||
AttentionFYI AttentionLevel = "FYI"
|
||||
AttentionBackground AttentionLevel = "BACKGROUND"
|
||||
)
|
||||
|
||||
type AttentionState string
|
||||
|
||||
const (
|
||||
AttentionPending AttentionState = "PENDING"
|
||||
AttentionDelivering AttentionState = "DELIVERING"
|
||||
AttentionDelivered AttentionState = "DELIVERED"
|
||||
AttentionResolved AttentionState = "RESOLVED"
|
||||
AttentionSuppressed AttentionState = "SUPPRESSED"
|
||||
AttentionExpired AttentionState = "EXPIRED"
|
||||
AttentionFailed AttentionState = "FAILED"
|
||||
)
|
||||
|
||||
type AttentionSourceKind string
|
||||
|
||||
const (
|
||||
AttentionSourceProspectiveReminder AttentionSourceKind = "PROSPECTIVE_REMINDER"
|
||||
)
|
||||
|
||||
// ProspectiveItem is canonical future relevance. TriggerAtUnixMillis is an
|
||||
// absolute instant; scheduling engines are derived delivery mechanisms only.
|
||||
type ProspectiveItem struct {
|
||||
@@ -34,11 +68,151 @@ 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
|
||||
}
|
||||
|
||||
// AttentionItem is Continuity-owned human-facing relevance state. It can be
|
||||
// rendered by UIs, but it is not a notification receipt and does not itself
|
||||
// grant authority for external mutation.
|
||||
type AttentionItem struct {
|
||||
ID AttentionItemID
|
||||
OwnerPrincipalID identity.PrincipalID
|
||||
ScopeID authority.ScopeID
|
||||
SourceKind AttentionSourceKind
|
||||
SourceProspectiveID ProspectiveItemID
|
||||
Level AttentionLevel
|
||||
State AttentionState
|
||||
Summary string
|
||||
DueAtUnixMillis int64
|
||||
CreatedAtUnixMillis int64
|
||||
DeliveredAtUnixMillis int64
|
||||
ActionIntentID CommittedActionIntentID
|
||||
AuthorizedActionID CommittedAuthorizedActionID
|
||||
ActionAttemptID CommittedActionAttemptID
|
||||
DeliveryOutcome string
|
||||
ResponseID HumanResponseID
|
||||
ResponseUtteranceID string
|
||||
ResponseOperation HumanResponseOperation
|
||||
ResponseOutcome ResponseOutcome
|
||||
RespondedAtUnixMillis int64
|
||||
Revision AttentionRevision
|
||||
}
|
||||
|
||||
type AttentionListResult struct {
|
||||
Items []AttentionItem
|
||||
}
|
||||
|
||||
type TodayItem struct {
|
||||
Title string
|
||||
WhenUnixMillis int64
|
||||
AttentionLevel AttentionLevel
|
||||
AttentionState AttentionState
|
||||
HumanState string
|
||||
DeliveryState string
|
||||
SourceDescription string
|
||||
ResponseHint string
|
||||
}
|
||||
|
||||
type TodayResult struct {
|
||||
Items []TodayItem
|
||||
}
|
||||
|
||||
type HumanResponseOperation string
|
||||
|
||||
const (
|
||||
ResponseAcknowledge HumanResponseOperation = "ACKNOWLEDGE"
|
||||
ResponseComplete HumanResponseOperation = "COMPLETE"
|
||||
ResponseSnooze HumanResponseOperation = "SNOOZE"
|
||||
ResponseCancel HumanResponseOperation = "CANCEL"
|
||||
ResponseDismiss HumanResponseOperation = "DISMISS"
|
||||
ResponseContinue HumanResponseOperation = "CONTINUE"
|
||||
ResponseReject HumanResponseOperation = "REJECT"
|
||||
ResponseUnknown HumanResponseOperation = "UNKNOWN"
|
||||
)
|
||||
|
||||
type ResponseTargetKind string
|
||||
|
||||
const (
|
||||
ResponseTargetAttentionItem ResponseTargetKind = "ATTENTION_ITEM"
|
||||
ResponseTargetProspectiveItem ResponseTargetKind = "PROSPECTIVE_ITEM"
|
||||
ResponseTargetWorkTask ResponseTargetKind = "WORK_TASK"
|
||||
ResponseTargetCapabilityGap ResponseTargetKind = "CAPABILITY_GAP"
|
||||
ResponseTargetInteractionContext ResponseTargetKind = "INTERACTION_CONTEXT"
|
||||
ResponseTargetUnknown ResponseTargetKind = "UNKNOWN"
|
||||
)
|
||||
|
||||
type ResponseResolutionState string
|
||||
|
||||
const (
|
||||
ResponseResolved ResponseResolutionState = "RESOLVED"
|
||||
ResponseAmbiguous ResponseResolutionState = "AMBIGUOUS"
|
||||
ResponseNoTarget ResponseResolutionState = "NO_TARGET"
|
||||
ResponseNoop ResponseResolutionState = "NOOP"
|
||||
)
|
||||
|
||||
type ResponseOutcome string
|
||||
|
||||
const (
|
||||
ResponseOutcomeUserReportedComplete ResponseOutcome = "USER_REPORTED_COMPLETE"
|
||||
ResponseOutcomeSnoozed ResponseOutcome = "SNOOZED"
|
||||
ResponseOutcomeCancelled ResponseOutcome = "CANCELLED"
|
||||
ResponseOutcomeDismissed ResponseOutcome = "DISMISSED"
|
||||
ResponseOutcomeClarificationNeeded ResponseOutcome = "CLARIFICATION_NEEDED"
|
||||
ResponseOutcomeNoop ResponseOutcome = "NOOP"
|
||||
)
|
||||
|
||||
type HumanResponse struct {
|
||||
ID HumanResponseID
|
||||
OwnerPrincipalID identity.PrincipalID
|
||||
ScopeID authority.ScopeID
|
||||
SourceUtteranceID string
|
||||
SourceIntentID string
|
||||
Operation HumanResponseOperation
|
||||
TargetKind ResponseTargetKind
|
||||
TargetAttentionID AttentionItemID
|
||||
TargetProspectiveID ProspectiveItemID
|
||||
TargetExternalRef string
|
||||
ResolutionState ResponseResolutionState
|
||||
Outcome ResponseOutcome
|
||||
Clarification string
|
||||
SnoozeUntilUnixMillis int64
|
||||
UserReportedNotVerified bool
|
||||
RecordedAtUnixMillis int64
|
||||
Revision HumanResponseRevision
|
||||
}
|
||||
|
||||
type HumanResponseRequest struct {
|
||||
SourceUtteranceID string
|
||||
SourceIntentID string
|
||||
Message string
|
||||
RequestedAtUnixMillis int64
|
||||
}
|
||||
|
||||
type HumanResponseResult struct {
|
||||
Response HumanResponse
|
||||
Handled bool
|
||||
Reply string
|
||||
}
|
||||
|
||||
// SatisfyDeliveredSubject is the complete serializable operation subject. It
|
||||
// is distinct from a Work TaskTarget, which remains the live justification.
|
||||
type SatisfyDeliveredSubject struct {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -407,6 +407,145 @@ 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;
|
||||
INSERT INTO capability.notification_bindings (
|
||||
binding_id, capability_id, owner_principal_id, revision, state, https_origin,
|
||||
delivery_path, receipt_path, protocol, secret_ref, verified_at_unix_millis, health
|
||||
)
|
||||
VALUES (
|
||||
'domovoy-notification-binding', 'prospective-notification-delivery', 'domovoy-creator-principal',
|
||||
2, 'ACTIVE', 'https://assistant.cin.su', '/delivery/v1/deliveries', '/delivery/v1/receipts',
|
||||
'IDEMPOTENT_RECEIPT_V1', 'domovoy-notification-write-v1', 1, 'AVAILABLE'
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO capability.notification_binding_heads(capability_id, owner_principal_id, binding_id, revision)
|
||||
VALUES ('prospective-notification-delivery', 'domovoy-creator-principal', 'domovoy-notification-binding', 2)
|
||||
ON CONFLICT (capability_id, owner_principal_id) DO UPDATE SET binding_id=EXCLUDED.binding_id, revision=EXCLUDED.revision
|
||||
WHERE capability.notification_binding_heads.revision < EXCLUDED.revision;
|
||||
RESET ROLE;
|
||||
|
||||
SET ROLE continuity_role;
|
||||
@@ -416,6 +555,9 @@ 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,
|
||||
@@ -436,6 +578,77 @@ CREATE TABLE IF NOT EXISTS continuity.action_receipts (
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (expected_revision > 0), CHECK (result_revision > expected_revision)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS continuity.attention_items (
|
||||
attention_item_id TEXT PRIMARY KEY,
|
||||
owner_principal_id TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
source_kind TEXT NOT NULL,
|
||||
source_prospective_item_id TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
due_at_unix_millis BIGINT NOT NULL,
|
||||
created_at_unix_millis BIGINT NOT NULL,
|
||||
delivered_at_unix_millis BIGINT NOT NULL DEFAULT 0,
|
||||
action_intent_id TEXT NOT NULL DEFAULT '',
|
||||
authorized_action_id TEXT NOT NULL DEFAULT '',
|
||||
action_attempt_id TEXT NOT NULL DEFAULT '',
|
||||
delivery_outcome TEXT NOT NULL DEFAULT '',
|
||||
response_id TEXT NOT NULL DEFAULT '',
|
||||
response_utterance_id TEXT NOT NULL DEFAULT '',
|
||||
response_operation TEXT NOT NULL DEFAULT '',
|
||||
response_outcome TEXT NOT NULL DEFAULT '',
|
||||
responded_at_unix_millis BIGINT NOT NULL DEFAULT 0,
|
||||
revision BIGINT NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (source_kind, source_prospective_item_id),
|
||||
CHECK (source_kind IN ('PROSPECTIVE_REMINDER')),
|
||||
CHECK (level IN ('URGENT','ACTION_REQUIRED','DUE','IMPORTANT_CHANGE','FYI','BACKGROUND')),
|
||||
CHECK (state IN ('PENDING','DELIVERING','DELIVERED','RESOLVED','SUPPRESSED','EXPIRED','FAILED')),
|
||||
CHECK (summary <> ''),
|
||||
CHECK (due_at_unix_millis > 0),
|
||||
CHECK (created_at_unix_millis > 0),
|
||||
CHECK (revision > 0),
|
||||
CHECK (state <> 'DELIVERED' OR (delivered_at_unix_millis > 0 AND action_intent_id <> '' AND authorized_action_id <> '' AND action_attempt_id <> '' AND delivery_outcome <> '')),
|
||||
CHECK (state <> 'RESOLVED' OR (
|
||||
response_id <> '' AND response_utterance_id <> '' AND
|
||||
response_operation IN ('ACKNOWLEDGE','COMPLETE','SNOOZE','CANCEL','DISMISS','CONTINUE','REJECT') AND
|
||||
response_outcome IN ('USER_REPORTED_COMPLETE','SNOOZED','CANCELLED','DISMISSED','NOOP') AND
|
||||
responded_at_unix_millis > 0
|
||||
))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS continuity.human_responses (
|
||||
human_response_id TEXT PRIMARY KEY,
|
||||
owner_principal_id TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
source_utterance_id TEXT NOT NULL,
|
||||
source_intent_id TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
target_kind TEXT NOT NULL,
|
||||
target_attention_id TEXT NOT NULL DEFAULT '',
|
||||
target_prospective_item_id TEXT NOT NULL DEFAULT '',
|
||||
target_external_ref TEXT NOT NULL DEFAULT '',
|
||||
resolution_state TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
clarification TEXT NOT NULL DEFAULT '',
|
||||
snooze_until_unix_millis BIGINT NOT NULL DEFAULT 0,
|
||||
user_reported_not_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
recorded_at_unix_millis BIGINT NOT NULL,
|
||||
revision BIGINT NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (owner_principal_id <> ''),
|
||||
CHECK (scope_id <> ''),
|
||||
CHECK (source_utterance_id <> ''),
|
||||
CHECK (source_intent_id <> ''),
|
||||
CHECK (operation IN ('ACKNOWLEDGE','COMPLETE','SNOOZE','CANCEL','DISMISS','CONTINUE','REJECT','UNKNOWN')),
|
||||
CHECK (target_kind IN ('ATTENTION_ITEM','PROSPECTIVE_ITEM','WORK_TASK','CAPABILITY_GAP','INTERACTION_CONTEXT','UNKNOWN')),
|
||||
CHECK (resolution_state IN ('RESOLVED','AMBIGUOUS','NO_TARGET','NOOP')),
|
||||
CHECK (outcome IN ('USER_REPORTED_COMPLETE','SNOOZED','CANCELLED','DISMISSED','CLARIFICATION_NEEDED','NOOP')),
|
||||
CHECK (recorded_at_unix_millis > 0),
|
||||
CHECK (revision > 0),
|
||||
CHECK (operation <> 'SNOOZE' OR snooze_until_unix_millis > recorded_at_unix_millis),
|
||||
CHECK (resolution_state <> 'RESOLVED' OR target_kind <> 'UNKNOWN')
|
||||
);
|
||||
RESET ROLE;
|
||||
|
||||
SET ROLE interaction_role;
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,10 @@
|
||||
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 '';
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,35 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE continuity_role;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS continuity.attention_items (
|
||||
attention_item_id TEXT PRIMARY KEY,
|
||||
owner_principal_id TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
source_kind TEXT NOT NULL,
|
||||
source_prospective_item_id TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
due_at_unix_millis BIGINT NOT NULL,
|
||||
created_at_unix_millis BIGINT NOT NULL,
|
||||
delivered_at_unix_millis BIGINT NOT NULL DEFAULT 0,
|
||||
action_intent_id TEXT NOT NULL DEFAULT '',
|
||||
authorized_action_id TEXT NOT NULL DEFAULT '',
|
||||
action_attempt_id TEXT NOT NULL DEFAULT '',
|
||||
delivery_outcome TEXT NOT NULL DEFAULT '',
|
||||
revision BIGINT NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (source_kind, source_prospective_item_id),
|
||||
CHECK (source_kind IN ('PROSPECTIVE_REMINDER')),
|
||||
CHECK (level IN ('URGENT','ACTION_REQUIRED','DUE','IMPORTANT_CHANGE','FYI','BACKGROUND')),
|
||||
CHECK (state IN ('PENDING','DELIVERING','DELIVERED','SUPPRESSED','EXPIRED','FAILED')),
|
||||
CHECK (summary <> ''),
|
||||
CHECK (due_at_unix_millis > 0),
|
||||
CHECK (created_at_unix_millis > 0),
|
||||
CHECK (revision > 0),
|
||||
CHECK (state <> 'DELIVERED' OR (delivered_at_unix_millis > 0 AND action_intent_id <> '' AND authorized_action_id <> '' AND action_attempt_id <> '' AND delivery_outcome <> ''))
|
||||
);
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,43 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE capability_role;
|
||||
|
||||
INSERT INTO capability.notification_bindings (
|
||||
binding_id, capability_id, owner_principal_id, revision, state, https_origin,
|
||||
delivery_path, receipt_path, protocol, secret_ref, verified_at_unix_millis, health
|
||||
)
|
||||
VALUES (
|
||||
'domovoy-notification-binding',
|
||||
'prospective-notification-delivery',
|
||||
'domovoy-creator-principal',
|
||||
2,
|
||||
'ACTIVE',
|
||||
'https://assistant.cin.su',
|
||||
'/delivery/v1/deliveries',
|
||||
'/delivery/v1/receipts',
|
||||
'IDEMPOTENT_RECEIPT_V1',
|
||||
'domovoy-notification-write-v1',
|
||||
FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint,
|
||||
'AVAILABLE'
|
||||
)
|
||||
ON CONFLICT (binding_id, revision) DO UPDATE SET
|
||||
https_origin = EXCLUDED.https_origin,
|
||||
verified_at_unix_millis = EXCLUDED.verified_at_unix_millis,
|
||||
health = EXCLUDED.health;
|
||||
|
||||
INSERT INTO capability.notification_binding_heads (
|
||||
capability_id, owner_principal_id, binding_id, revision
|
||||
)
|
||||
VALUES (
|
||||
'prospective-notification-delivery',
|
||||
'domovoy-creator-principal',
|
||||
'domovoy-notification-binding',
|
||||
2
|
||||
)
|
||||
ON CONFLICT (capability_id, owner_principal_id) DO UPDATE SET
|
||||
binding_id = EXCLUDED.binding_id,
|
||||
revision = EXCLUDED.revision
|
||||
WHERE capability.notification_binding_heads.revision < EXCLUDED.revision;
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,84 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE continuity_role;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS continuity.human_responses (
|
||||
human_response_id TEXT PRIMARY KEY,
|
||||
owner_principal_id TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
source_utterance_id TEXT NOT NULL,
|
||||
source_intent_id TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
target_kind TEXT NOT NULL,
|
||||
target_attention_id TEXT NOT NULL DEFAULT '',
|
||||
target_prospective_item_id TEXT NOT NULL DEFAULT '',
|
||||
target_external_ref TEXT NOT NULL DEFAULT '',
|
||||
resolution_state TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
clarification TEXT NOT NULL DEFAULT '',
|
||||
snooze_until_unix_millis BIGINT NOT NULL DEFAULT 0,
|
||||
user_reported_not_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
recorded_at_unix_millis BIGINT NOT NULL,
|
||||
revision BIGINT NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (owner_principal_id <> ''),
|
||||
CHECK (scope_id <> ''),
|
||||
CHECK (source_utterance_id <> ''),
|
||||
CHECK (source_intent_id <> ''),
|
||||
CHECK (operation IN ('ACKNOWLEDGE','COMPLETE','SNOOZE','CANCEL','DISMISS','CONTINUE','REJECT','UNKNOWN')),
|
||||
CHECK (target_kind IN ('ATTENTION_ITEM','PROSPECTIVE_ITEM','WORK_TASK','CAPABILITY_GAP','INTERACTION_CONTEXT','UNKNOWN')),
|
||||
CHECK (resolution_state IN ('RESOLVED','AMBIGUOUS','NO_TARGET','NOOP')),
|
||||
CHECK (outcome IN ('USER_REPORTED_COMPLETE','SNOOZED','CANCELLED','DISMISSED','CLARIFICATION_NEEDED','NOOP')),
|
||||
CHECK (recorded_at_unix_millis > 0),
|
||||
CHECK (revision > 0),
|
||||
CHECK (operation <> 'SNOOZE' OR snooze_until_unix_millis > recorded_at_unix_millis),
|
||||
CHECK (resolution_state <> 'RESOLVED' OR target_kind <> 'UNKNOWN')
|
||||
);
|
||||
|
||||
ALTER TABLE continuity.attention_items
|
||||
ADD COLUMN IF NOT EXISTS response_id TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS response_utterance_id TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS response_operation TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS response_outcome TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS responded_at_unix_millis BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
constraint_record RECORD;
|
||||
BEGIN
|
||||
FOR constraint_record IN
|
||||
SELECT conname
|
||||
FROM pg_constraint
|
||||
WHERE conrelid = 'continuity.attention_items'::regclass
|
||||
AND contype = 'c'
|
||||
AND pg_get_constraintdef(oid) LIKE '%state%'
|
||||
AND pg_get_constraintdef(oid) LIKE '%DELIVERED%'
|
||||
AND pg_get_constraintdef(oid) LIKE '%FAILED%'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE continuity.attention_items DROP CONSTRAINT %I', constraint_record.conname);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE continuity.attention_items
|
||||
ADD CONSTRAINT attention_items_state_check
|
||||
CHECK (state IN ('PENDING','DELIVERING','DELIVERED','RESOLVED','SUPPRESSED','EXPIRED','FAILED'));
|
||||
|
||||
ALTER TABLE continuity.attention_items
|
||||
ADD CONSTRAINT attention_items_delivered_evidence_check
|
||||
CHECK (state <> 'DELIVERED' OR (delivered_at_unix_millis > 0 AND action_intent_id <> '' AND authorized_action_id <> '' AND action_attempt_id <> '' AND delivery_outcome <> ''));
|
||||
|
||||
ALTER TABLE continuity.attention_items
|
||||
ADD CONSTRAINT attention_items_response_projection_check
|
||||
CHECK (
|
||||
state <> 'RESOLVED'
|
||||
OR (
|
||||
response_id <> ''
|
||||
AND response_utterance_id <> ''
|
||||
AND response_operation IN ('ACKNOWLEDGE','COMPLETE','SNOOZE','CANCEL','DISMISS','CONTINUE','REJECT')
|
||||
AND response_outcome IN ('USER_REPORTED_COMPLETE','SNOOZED','CANCELLED','DISMISSED','NOOP')
|
||||
AND responded_at_unix_millis > 0
|
||||
)
|
||||
);
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"task_file": "dev/tasks/DEV-000063-intent-work-planning-foundation.json",
|
||||
"task_id": "DEV-000063",
|
||||
"task_file": "dev/tasks/DEV-000066-human-response-continuation-semantics.json",
|
||||
"task_id": "DEV-000066",
|
||||
"task_revision": 1
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
{
|
||||
"task_id": "DEV-000065",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0014",
|
||||
"base_repo_revision": "6e73318e49991b40a5ed750c604792cbd95bd325",
|
||||
"risk_tier": "E",
|
||||
"intent": "Add assistant presence through Continuity-owned due reminder processing, typed Attention foundation, governed outbound notification delivery, and a human-facing Assistant surface with Chat, Today/Attention, and History while preserving the Operator Console.",
|
||||
"requirement_ids": [
|
||||
"ADR-0014",
|
||||
"ADR-0005",
|
||||
"INV-AUTH-001",
|
||||
"INV-OWN-001",
|
||||
"INV-CONT-001",
|
||||
"INV-EPI-001",
|
||||
"Capability availability is not Authority",
|
||||
"Durable future obligations survive runtime restart"
|
||||
],
|
||||
"allowed_change_scope": {
|
||||
"modules": [
|
||||
"continuity",
|
||||
"commit",
|
||||
"capability",
|
||||
"interaction"
|
||||
],
|
||||
"paths": [
|
||||
"architecture/adrs/ADR-0014-assistant-presence-attention-human-surface.md",
|
||||
"architecture/architecture-registry.yaml",
|
||||
"architecture/architecture-registry.json",
|
||||
"contracts/continuity/**",
|
||||
"contracts/commit/**",
|
||||
"contracts/capability/**",
|
||||
"modules/continuity/**",
|
||||
"modules/commit/**",
|
||||
"adapters/postgres/**",
|
||||
"database/bootstrap/**",
|
||||
"database/migrations/**",
|
||||
"cmd/continuity-runtime/**",
|
||||
"cmd/commit-runtime/**",
|
||||
"cmd/core-runtime/**",
|
||||
"cmd/creator-web/**",
|
||||
"tests/owner_acl/**",
|
||||
"tests/cognitive_advisory/**",
|
||||
"docs/PROJECT_STATUS.md",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/DEV-000065-assistant-presence-attention-human-surface.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": [
|
||||
"architecture/ARCHITECTURE_CONSTITUTION.md",
|
||||
"security/**",
|
||||
"ci/**",
|
||||
".gitea/**"
|
||||
]
|
||||
},
|
||||
"allowed_architecture_delta": {
|
||||
"new_cross_module_dependencies": 1,
|
||||
"new_canonical_owners": 0,
|
||||
"new_privileges": 0,
|
||||
"new_third_party_dependencies": 0,
|
||||
"new_provider_adapters": 0
|
||||
},
|
||||
"contracts": {
|
||||
"consume": [
|
||||
"Continuity ProspectiveItem",
|
||||
"Commit prospective notification operation",
|
||||
"Capability notification binding",
|
||||
"Interaction ChatMessage",
|
||||
"Authority Creator grant",
|
||||
"CredentialRef Secret Resolver foundation"
|
||||
],
|
||||
"may_change": [
|
||||
"contracts/continuity@v1",
|
||||
"contracts/commit@v1"
|
||||
]
|
||||
},
|
||||
"semantic_owner": "continuity",
|
||||
"canonical_state": [
|
||||
"ProspectiveItem",
|
||||
"AttentionItem"
|
||||
],
|
||||
"state_transitions": [
|
||||
"ProspectiveItem ARMED -> TRIGGERED -> REVALIDATING -> DELIVERED for due reminder processing",
|
||||
"AttentionItem PENDING -> DELIVERING -> DELIVERED for human contact outcome",
|
||||
"No ACTIVE cognitive binding promotion",
|
||||
"No OpenAI activation"
|
||||
],
|
||||
"side_effects_and_privileges": [
|
||||
"Continuity runtime polls canonical prospective state and does not own external mutation",
|
||||
"Commit runtime remains the only outbound notification executor boundary",
|
||||
"Notification delivery uses existing CapabilityBinding and idempotent receiver protocol",
|
||||
"Human Assistant UI reads canonical runtime APIs and hides internal identifiers by default",
|
||||
"Docker-test remains prohibited"
|
||||
],
|
||||
"persistence_and_migrations": [
|
||||
"continuity owner-local attention_items table",
|
||||
"no cross-owner FK",
|
||||
"no cross-owner SQL join dependency",
|
||||
"Commit authorized action and attempt persistence remains Commit-owned",
|
||||
"Notification receipt persistence remains receiver-owned delivery evidence"
|
||||
],
|
||||
"failure_retry_restart": [
|
||||
"Due detection reads ARMED rows from canonical PostgreSQL state",
|
||||
"CAS/unique source constraints prevent duplicate AttentionItem creation",
|
||||
"Successful delivery moves AttentionItem to DELIVERED and prevents repeated delivery",
|
||||
"Unavailable LLM does not block deterministic reminder firing",
|
||||
"Unavailable notification delivery records non-success without inventing completion"
|
||||
],
|
||||
"assumptions": [
|
||||
"DEV-000064 is production baseline",
|
||||
"Continuity is canonical owner of future obligations and attention state for this generation",
|
||||
"Commit and existing notification receiver remain the outbound mutation path",
|
||||
"The first human notification channel is the existing production receiver/Windows display path",
|
||||
"CPU CANARY may remain available but reminder firing is deterministic and LLM-free"
|
||||
],
|
||||
"acceptance_scenarios": [
|
||||
"Reminder becomes due",
|
||||
"Due reminder produces governed AttentionItem",
|
||||
"Attention can produce notification through Commit",
|
||||
"Delivery outcome is persisted",
|
||||
"No duplicate delivery after success",
|
||||
"Runtime restart does not lose an ARMED reminder",
|
||||
"Cancelled/suppressed reminder is not delivered",
|
||||
"LLM unavailable does not prevent deterministic reminder delivery",
|
||||
"No cognitive model is required merely to fire a known reminder",
|
||||
"CapabilityGap is rendered human-readable",
|
||||
"Human UI requires no internal IDs",
|
||||
"Operator Console remains available",
|
||||
"Authority boundaries remain unchanged",
|
||||
"Owner ACL passes",
|
||||
"LLM-free audit passes",
|
||||
"Production live delayed reminder delivers once",
|
||||
"Today/Attention surface is built from canonical state"
|
||||
],
|
||||
"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 delayed reminder acceptance",
|
||||
"production /chat and /healthz verification"
|
||||
],
|
||||
"terminal_gap_states": [
|
||||
"BLOCKED_BY_ARCHITECTURE_GAP",
|
||||
"BLOCKED_BY_SPEC_GAP",
|
||||
"BLOCKED_BY_MISSING_CAPABILITY",
|
||||
"BLOCKED_BY_CONFLICT"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"task_id": "DEV-000066",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0015",
|
||||
"base_repo_revision": "e8e51ffdba38ab901e275283e46f2b45bb6affe9",
|
||||
"risk_tier": "E",
|
||||
"intent": "Add provider-neutral human response and continuation semantics for existing context, with reminders/attention as the first implementation and no reminder-specific parallel subsystem.",
|
||||
"requirement_ids": [
|
||||
"ADR-0015",
|
||||
"INV-AUTH-001",
|
||||
"INV-OWN-001",
|
||||
"INV-CONT-001",
|
||||
"INV-EPI-001",
|
||||
"Human response is not automatic world truth",
|
||||
"Contextual response target resolution without internal IDs"
|
||||
,
|
||||
"ActiveProjectFocus is contextual evidence, not a global user mode"
|
||||
],
|
||||
"allowed_change_scope": {
|
||||
"modules": [
|
||||
"continuity",
|
||||
"interaction"
|
||||
],
|
||||
"paths": [
|
||||
"architecture/adrs/ADR-0015-human-response-continuation-semantics.md",
|
||||
"architecture/architecture-registry.yaml",
|
||||
"architecture/architecture-registry.json",
|
||||
"contracts/continuity/**",
|
||||
"contracts/interaction/**",
|
||||
"modules/continuity/**",
|
||||
"modules/interaction/**",
|
||||
"adapters/postgres/**",
|
||||
"database/bootstrap/**",
|
||||
"database/migrations/**",
|
||||
"cmd/continuity-runtime/**",
|
||||
"cmd/core-runtime/**",
|
||||
"cmd/creator-web/**",
|
||||
"tests/owner_acl/**",
|
||||
"tests/cognitive_advisory/**",
|
||||
"docs/PROJECT_STATUS.md",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/DEV-000066-human-response-continuation-semantics.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": [
|
||||
"architecture/ARCHITECTURE_CONSTITUTION.md",
|
||||
"modules/authority/**",
|
||||
"modules/commit/**",
|
||||
"cmd/commit-runtime/**",
|
||||
"security/**",
|
||||
"ci/**",
|
||||
".gitea/**"
|
||||
]
|
||||
},
|
||||
"allowed_architecture_delta": {
|
||||
"new_cross_module_dependencies": 0,
|
||||
"new_canonical_owners": 0,
|
||||
"new_privileges": 0,
|
||||
"new_third_party_dependencies": 0,
|
||||
"new_provider_adapters": 0
|
||||
},
|
||||
"contracts": {
|
||||
"consume": [
|
||||
"Interaction HumanUtterance / OriginalHumanIntent",
|
||||
"Continuity ProspectiveItem",
|
||||
"Continuity AttentionItem"
|
||||
],
|
||||
"may_change": [
|
||||
"contracts/continuity@v1"
|
||||
]
|
||||
},
|
||||
"semantic_owner": "continuity",
|
||||
"canonical_state": [
|
||||
"HumanResponse",
|
||||
"ResponseTargetResolution",
|
||||
"ProspectiveItem",
|
||||
"AttentionItem"
|
||||
],
|
||||
"state_transitions": [
|
||||
"single obvious delivered reminder attention + COMPLETE -> ProspectiveItem SATISFIED and Attention RESOLVED",
|
||||
"single obvious delivered reminder attention + SNOOZE -> same ProspectiveItem ARMED at new due time and Attention RESOLVED",
|
||||
"single obvious reminder/attention + CANCEL -> ProspectiveItem SUPPRESSED and Attention SUPPRESSED when present",
|
||||
"ambiguous target -> clarification response record and no semantic mutation"
|
||||
],
|
||||
"side_effects_and_privileges": [
|
||||
"No external mutation is introduced",
|
||||
"Human responses mutate only Continuity-owned state through Continuity runtime",
|
||||
"User-reported completion is recorded as USER_REPORTED_COMPLETE, not verified world truth",
|
||||
"No cognitive model is required for obvious deterministic response operations",
|
||||
"Docker-test remains prohibited"
|
||||
],
|
||||
"persistence_and_migrations": [
|
||||
"continuity.human_responses append-only table",
|
||||
"Attention response closure fields",
|
||||
"no cross-owner FK",
|
||||
"no cross-owner SQL join dependency"
|
||||
],
|
||||
"failure_retry_restart": [
|
||||
"HumanResponse rows are append-only audit/history",
|
||||
"Ambiguous target resolution fails closed with clarification",
|
||||
"Snoozed reminders are re-armed in canonical prospective state and survive restart",
|
||||
"Cancelled/suppressed reminders are excluded by the existing due scanner"
|
||||
],
|
||||
"assumptions": [
|
||||
"DEV-000065 is production baseline",
|
||||
"Continuity remains canonical owner for future obligation continuation",
|
||||
"Interaction remains canonical owner of utterances and original human intent",
|
||||
"Buttons are optional UI projection over the same chat response endpoint"
|
||||
],
|
||||
"acceptance_scenarios": [
|
||||
"Single active reminder plus 'Готово' resolves the correct target",
|
||||
"Single active reminder plus 'Отложи на час' reschedules the same prospective item",
|
||||
"'Отмени' suppresses the reminder and prevents future delivery",
|
||||
"Two possible targets plus ambiguous 'Отмени' asks clarification and does not mutate",
|
||||
"Historical utterances remain append-only",
|
||||
"Attention closes appropriately",
|
||||
"User-reported completion does not become verified world fact",
|
||||
"Obvious response operations work LLM-free",
|
||||
"Cognition unavailable does not break obvious operations",
|
||||
"UI buttons and natural language use the same backend semantics",
|
||||
"Future non-reminder response target kinds can be represented"
|
||||
,
|
||||
"Unrelated new human intents do not inherit stale ActiveProjectFocus",
|
||||
"Human UI does not expose technical project provenance labels"
|
||||
],
|
||||
"required_tests": [
|
||||
"go test ./...",
|
||||
"go vet ./...",
|
||||
"go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json",
|
||||
"go run ./cmd/devctl check-architecture",
|
||||
"go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json",
|
||||
"tests/cognitive_advisory/cognitive_worker_env_policy_test.ps1",
|
||||
"tests/owner_acl/verify.ps1 -DockerContext docker-prod -PostgresContainer assistant-postgres-1",
|
||||
"production live complete/snooze/cancel acceptance",
|
||||
"production /chat and /today verification"
|
||||
],
|
||||
"terminal_gap_states": [
|
||||
"BLOCKED_BY_ARCHITECTURE_GAP",
|
||||
"BLOCKED_BY_SPEC_GAP",
|
||||
"BLOCKED_BY_MISSING_CAPABILITY",
|
||||
"BLOCKED_BY_CONFLICT"
|
||||
]
|
||||
}
|
||||
+61
-5
@@ -1,17 +1,73 @@
|
||||
# Assistant project status
|
||||
|
||||
Audit date: 2026-08-17. Active implementation: DEV-000063 revision 1.
|
||||
Audit date: 2026-08-17. Active implementation: DEV-000066 revision 1.
|
||||
|
||||
## DEV-000066 checkpoint
|
||||
|
||||
- `DEV_000066_STATUS = IMPLEMENTED_NOT_PROMOTED`
|
||||
- `LAST_COMPLETED = ADR-0015 accepted; Continuity-owned HumanResponse contract foundation; ResponseTargetResolution semantics; append-only human_responses migration; Attention response projection fields; deterministic COMPLETE/SNOOZE/CANCEL/DISMISS interpretation; Creator Web response buttons over ordinary /chat; ActiveProjectFocus leak regression under Interaction semantics`
|
||||
- `ADR_STATUS = ADR-0015 accepted`
|
||||
- `OWNERSHIP_STATUS = no new owner; HumanResponse and response target resolution belong to Continuity; Interaction remains utterance/original-intent owner`
|
||||
- `REMINDER_RESPONSE_STATUS = COMPLETE records USER_REPORTED_COMPLETE, SNOOZE rearms same ProspectiveItem, CANCEL suppresses reminder; ambiguous target asks clarification`
|
||||
- `CONTEXT_STATUS = ActiveProjectFocus treated as contextual evidence, not global mode; unrelated new intents do not inherit stale project focus`
|
||||
- `HUMAN_SURFACE_STATUS = /chat remains primary; Today buttons send natural language through the same backend semantics`
|
||||
- `AUTHORITY_STATUS = no new external mutation authority; human reported completion is not verified world truth`
|
||||
- `LLM_FREE_STATUS = obvious response operations are deterministic and do not require cognitive model`
|
||||
- `TESTS_PASS = go test ./...; go vet ./...; go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json; go run ./cmd/devctl check-architecture; go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json; tests/cognitive_advisory/cognitive_worker_env_policy_test.ps1; tests/owner_acl/verify.ps1 -DockerContext docker-prod -PostgresContainer assistant-postgres-1`
|
||||
- `LIVE_ACCEPTANCE = pending production deployment`
|
||||
- `PRODUCTION_STATUS = pending promotion/deployment`
|
||||
- `ACTIVE_COGNITIVE_BINDINGS = none; CPU CANARY unchanged`
|
||||
- `OPENAI_REAL_CREDENTIAL = not required / not activated`
|
||||
|
||||
## DEV-000065 checkpoint
|
||||
|
||||
- `DEV_000065_STATUS = COMPLETED`
|
||||
- `LAST_COMPLETED = ADR-0014 accepted; Continuity-owned AttentionItem contract and persistence; due reminder scanner; Commit internal prospective notification endpoint; human-facing Chat / Today / History surface; relative reminder parsing for "через N минут"; deterministic due-delivery regression tests; notification binding origin corrected to assistant.cin.su revision 2`
|
||||
- `ADR_STATUS = ADR-0014 accepted`
|
||||
- `OWNERSHIP_STATUS = no new owner; AttentionItem belongs to Continuity; notification delivery remains Commit/Capability/receiver path`
|
||||
- `REMINDER_LIFECYCLE_STATUS = ARMED -> TRIGGERED -> REVALIDATING -> DELIVERED implemented for due scanner`
|
||||
- `REMINDER_DELIVERY_STATUS = PASS: production live delayed reminder delivered through Commit deliver-prospective-notification-v1 and immutable receiver receipt`
|
||||
- `ATTENTION_STATUS = typed AttentionItem PENDING -> DELIVERING -> DELIVERED foundation implemented`
|
||||
- `HUMAN_SURFACE_STATUS = /chat remains primary; Today/Attention and History added; Operator Console preserved at /`
|
||||
- `AUTHORITY_STATUS = external notification still requires Commit authorization, current Creator authority context, and active Capability notification binding`
|
||||
- `LLM_FREE_STATUS = deterministic reminder firing does not require cognitive model; LLM-free env policy regression PASS`
|
||||
- `TESTS_PASS = go test ./...; go vet ./...; go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json; go run ./cmd/devctl check-architecture; go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json; tests/cognitive_advisory/cognitive_worker_env_policy_test.ps1; tests/owner_acl/verify.ps1 -DockerContext docker-prod -PostgresContainer assistant-postgres-1; production live delayed reminder acceptance; production /chat attention and capability-gap acceptance`
|
||||
- `LIVE_ACCEPTANCE = PASS: "Напомни мне через 3 минуты проверить уведомление." persisted ARMED reminder, transitioned to DELIVERED, generated AttentionItem, delivered notification through Commit, persisted SUCCEEDED attempt and receiver receipt, duplicate counts action_attempts=1 attention_items=1; "Отправь Ивану письмо." returned readable capability gap; "Что сейчас требует моего внимания?" answered from canonical Today with cognition=not_requested`
|
||||
- `PRODUCTION_STATUS = healthy`
|
||||
- `PRODUCTION_REVISION = origin/main 1113ced623896f1498d606653d71f36c361e9bb0; creator-web deployed at 1113ced623896f1498d606653d71f36c361e9bb0; continuity-runtime deployed at 2ec53e40cf19c609193c1aa74955573cac7abfe1; core-runtime/commit-runtime deployed at a0dce634f3c6efec0481f730c9dcc12797749ec4`
|
||||
- `KNOWN_LIMITATION = one early DEV-000065 live probe occurred before notification binding origin revision 2 and produced UNKNOWN_OUTCOME; later regression prevents UNKNOWN_OUTCOME from being marked as delivered`
|
||||
- `NEXT_STEP = Human response operations for attention/reminders: Done/Snooze/Cancel semantic commands, if separately approved`
|
||||
|
||||
## DEV-000064 checkpoint
|
||||
|
||||
- `DEV_000064_STATUS = COMPLETED`
|
||||
- `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; tests/owner_acl/verify.ps1 -DockerContext docker-prod -PostgresContainer assistant-postgres-1; production live /chat acceptance`
|
||||
- `LIVE_ACCEPTANCE = PASS: reminder created through external /chat and verified as Continuity ARMED prospective item; unsupported SEND_EMAIL created CapabilityGap; "Привет. Что ты умеешь?" returned capability-aware response; simple code explanation completed through Cognitive Gateway without durable Work`
|
||||
- `PRODUCTION_REVISION = 8fe596264e89b8f346c9e0cd297c5ccb2d5f76bb`
|
||||
- `PRODUCTION_HEALTH = healthy`
|
||||
- `NEXT_STEP = Direct delivery/revalidation loop for reminders or next concrete capability adapter, if separately approved`
|
||||
|
||||
## DEV-000063 checkpoint
|
||||
|
||||
- `DEV_000063_STATUS = IN_PROGRESS`
|
||||
- `LAST_COMPLETED = ADR-0012, HelpPath optionality foundation, Work-owned Goal/PlanCandidate/WorkPlan/PlanStep/CapabilityRequirement/DelegationCandidate contracts, deterministic planning V1, optional durable Work creation from /chat, capability-gap/self-extension readiness tests`
|
||||
- `DEV_000063_STATUS = COMPLETED`
|
||||
- `LAST_COMPLETED = ADR-0012, HelpPath optionality foundation, Work-owned Goal/PlanCandidate/WorkPlan/PlanStep/CapabilityRequirement/DelegationCandidate contracts, deterministic planning V1, optional durable Work creation from /chat, capability-gap/self-extension readiness tests, migration 017, production deployment and live /chat acceptance`
|
||||
- `HELP_PATH_OPTIONALITY = enabled; simple answer/cognition/direct capability/capability gap can bypass durable Goal/Work/Plan`
|
||||
- `GOAL_WORK_PLAN_OPTIONALITY = enabled; durable records are created only for complex work/planning cases`
|
||||
- `PROJECT_NEUTRALITY = project context is optional opaque Work reference; Project is not mandatory context`
|
||||
- `CODEX_NEUTRALITY = no Codex-specific canonical entity or executor selection`
|
||||
- `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`
|
||||
- `NEXT_STEP = commit, push, PR/promotion, migration 017, production deploy, live /chat acceptance`
|
||||
- `TESTS_PASS = go test ./...; go vet ./...; go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json; go run ./cmd/devctl check-architecture; go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json; tests/cognitive_advisory/cognitive_worker_env_policy_test.ps1; tests/owner_acl/verify.ps1 -DockerContext docker-prod -PostgresContainer assistant-postgres-1; production live /chat acceptance`
|
||||
- `LIVE_ACCEPTANCE = PASS: simple help uses cognition without Work; reminder uses direct scheduling-capability path without Work; complex УПО investigation creates one Work Goal/Plan; capability gap remains executor-neutral without Work`
|
||||
- `PRODUCTION_REVISION = 9d50a3ec635782b59ab3335808d6d1e026be48a4`
|
||||
- `PRODUCTION_HEALTH = healthy`
|
||||
- `NEXT_STEP = DEV-000064 capability resolution/acquisition or concrete scheduler/mail/codex adapter foundation, if separately approved`
|
||||
- `REAL_BLOCKERS = none known`
|
||||
|
||||
## DEV-000062 checkpoint
|
||||
|
||||
@@ -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))}}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -44,10 +44,17 @@ func Reconcile(action commitcontract.AuthorizedAction, previous commitcontract.A
|
||||
// Authorize is the Commit boundary. A proposal alone never reaches Executor.
|
||||
func Authorize(proposal commitcontract.ActionProposal, context AuthorizationContext, validator ports.AuthorityValidator, authorizedID commitcontract.AuthorizedActionID, authorizedAtUnixMillis int64) (commitcontract.AuthorizedAction, error) {
|
||||
if proposal.IntentID == "" || proposal.ActorID == "" || proposal.CapabilityID == capability.CapabilityID("") ||
|
||||
proposal.Target.TaskID == "" || proposal.Target.GoalID == "" || proposal.Target.ScopeID == "" || proposal.Target.Revision == 0 || proposal.Operation == "" || authorizedAtUnixMillis <= 0 {
|
||||
proposal.Operation == "" || authorizedAtUnixMillis <= 0 {
|
||||
return commitcontract.AuthorizedAction{}, ErrInvalidProposal
|
||||
}
|
||||
if proposal.ActorID != context.Binding.ActorID || proposal.Target.ScopeID != context.Scope.ID {
|
||||
if proposal.ActorID != context.Binding.ActorID {
|
||||
return commitcontract.AuthorizedAction{}, ErrInvalidProposal
|
||||
}
|
||||
if proposal.Operation == commitcontract.OperationDeliverProspectiveNotification {
|
||||
if proposal.Target.ScopeID != "" && proposal.Target.ScopeID != context.Scope.ID {
|
||||
return commitcontract.AuthorizedAction{}, ErrInvalidProposal
|
||||
}
|
||||
} else if proposal.Target.TaskID == "" || proposal.Target.GoalID == "" || proposal.Target.ScopeID == "" || proposal.Target.Revision == 0 || proposal.Target.ScopeID != context.Scope.ID {
|
||||
return commitcontract.AuthorizedAction{}, ErrInvalidProposal
|
||||
}
|
||||
if !allowedOperation(proposal, context) {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
contract "git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
var ErrInvalidHumanResponse = errors.New("invalid human response")
|
||||
|
||||
type ResponseCandidate struct {
|
||||
Attention contract.AttentionItem
|
||||
Prospective contract.ProspectiveItem
|
||||
}
|
||||
|
||||
func InterpretHumanResponse(request contract.HumanResponseRequest, owner identity.PrincipalID, scope authority.ScopeID, id contract.HumanResponseID, candidates []ResponseCandidate, now time.Time) (contract.HumanResponseResult, error) {
|
||||
message := strings.TrimSpace(request.Message)
|
||||
if id == "" || owner == "" || scope == "" || request.SourceUtteranceID == "" || request.SourceIntentID == "" || message == "" || request.RequestedAtUnixMillis < 1 {
|
||||
return contract.HumanResponseResult{}, ErrInvalidHumanResponse
|
||||
}
|
||||
operation, snoozeUntil := detectResponseOperation(message, now)
|
||||
response := contract.HumanResponse{
|
||||
ID: id,
|
||||
OwnerPrincipalID: owner,
|
||||
ScopeID: scope,
|
||||
SourceUtteranceID: strings.TrimSpace(request.SourceUtteranceID),
|
||||
SourceIntentID: strings.TrimSpace(request.SourceIntentID),
|
||||
Operation: operation,
|
||||
TargetKind: contract.ResponseTargetUnknown,
|
||||
ResolutionState: contract.ResponseNoop,
|
||||
Outcome: contract.ResponseOutcomeNoop,
|
||||
SnoozeUntilUnixMillis: snoozeUntil.UnixMilli(),
|
||||
RecordedAtUnixMillis: now.UnixMilli(),
|
||||
Revision: 1,
|
||||
UserReportedNotVerified: false,
|
||||
}
|
||||
if operation == contract.ResponseUnknown || operation == contract.ResponseContinue || operation == contract.ResponseReject {
|
||||
return contract.HumanResponseResult{Response: response, Handled: false}, nil
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
response.ResolutionState = contract.ResponseNoTarget
|
||||
response.Outcome = contract.ResponseOutcomeClarificationNeeded
|
||||
response.Clarification = "Я не вижу активного ожидания, к которому это относится."
|
||||
return contract.HumanResponseResult{Response: response, Handled: true, Reply: response.Clarification}, nil
|
||||
}
|
||||
if len(candidates) > 1 {
|
||||
matched := matchingCandidates(message, candidates)
|
||||
if len(matched) == 1 {
|
||||
candidates = matched
|
||||
}
|
||||
}
|
||||
if len(candidates) > 1 {
|
||||
response.ResolutionState = contract.ResponseAmbiguous
|
||||
response.Outcome = contract.ResponseOutcomeClarificationNeeded
|
||||
response.Clarification = "У меня есть несколько возможных ожиданий. Уточните, о каком именно речь: " + candidateSummary(candidates)
|
||||
return contract.HumanResponseResult{Response: response, Handled: true, Reply: response.Clarification}, nil
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.Attention.ID != "" {
|
||||
response.TargetKind = contract.ResponseTargetAttentionItem
|
||||
response.TargetAttentionID = candidate.Attention.ID
|
||||
response.TargetProspectiveID = candidate.Attention.SourceProspectiveID
|
||||
} else {
|
||||
response.TargetKind = contract.ResponseTargetProspectiveItem
|
||||
response.TargetProspectiveID = candidate.Prospective.ID
|
||||
}
|
||||
response.ResolutionState = contract.ResponseResolved
|
||||
switch operation {
|
||||
case contract.ResponseComplete:
|
||||
response.Outcome = contract.ResponseOutcomeUserReportedComplete
|
||||
response.UserReportedNotVerified = true
|
||||
return contract.HumanResponseResult{Response: response, Handled: true, Reply: "Готово, я закрыл это напоминание. Записал как «сообщено человеком», без внешней проверки."}, nil
|
||||
case contract.ResponseSnooze:
|
||||
if response.SnoozeUntilUnixMillis <= now.UnixMilli() {
|
||||
response.ResolutionState = contract.ResponseNoop
|
||||
response.Outcome = contract.ResponseOutcomeClarificationNeeded
|
||||
response.Clarification = "На сколько отложить? Например: «Отложи на 10 минут»."
|
||||
return contract.HumanResponseResult{Response: response, Handled: true, Reply: response.Clarification}, nil
|
||||
}
|
||||
response.Outcome = contract.ResponseOutcomeSnoozed
|
||||
return contract.HumanResponseResult{Response: response, Handled: true, Reply: "Отложил. Напомню " + humanResponseTime(snoozeUntil) + "."}, nil
|
||||
case contract.ResponseCancel:
|
||||
response.Outcome = contract.ResponseOutcomeCancelled
|
||||
return contract.HumanResponseResult{Response: response, Handled: true, Reply: "Отменил. Больше не буду напоминать об этом."}, nil
|
||||
case contract.ResponseAcknowledge, contract.ResponseDismiss:
|
||||
response.Outcome = contract.ResponseOutcomeDismissed
|
||||
return contract.HumanResponseResult{Response: response, Handled: true, Reply: "Принял, убрал это из текущего внимания."}, nil
|
||||
default:
|
||||
response.ResolutionState = contract.ResponseNoop
|
||||
response.Outcome = contract.ResponseOutcomeNoop
|
||||
return contract.HumanResponseResult{Response: response, Handled: false}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func detectResponseOperation(message string, now time.Time) (contract.HumanResponseOperation, time.Time) {
|
||||
lower := strings.ToLower(strings.TrimSpace(message))
|
||||
if containsAny(lower, "отложи", "перенеси", "позже", "потом") {
|
||||
if until, ok := parseSnoozeUntil(lower, now); ok {
|
||||
return contract.ResponseSnooze, until
|
||||
}
|
||||
return contract.ResponseSnooze, time.Time{}
|
||||
}
|
||||
if containsAny(lower, "отмени", "отменить", "не надо", "не нужно", "больше не напоминай") {
|
||||
return contract.ResponseCancel, time.Time{}
|
||||
}
|
||||
if containsAny(lower, "готово", "сделал", "сделала", "выполнено", "закрыл", "закрыла") {
|
||||
return contract.ResponseComplete, time.Time{}
|
||||
}
|
||||
if lower == "ок" || lower == "понял" || lower == "поняла" || lower == "принято" {
|
||||
return contract.ResponseAcknowledge, time.Time{}
|
||||
}
|
||||
if containsAny(lower, "продолжай", "дальше") {
|
||||
return contract.ResponseContinue, time.Time{}
|
||||
}
|
||||
if containsAny(lower, "отклоняю", "нет", "не согласен", "не согласна") {
|
||||
return contract.ResponseReject, time.Time{}
|
||||
}
|
||||
return contract.ResponseUnknown, time.Time{}
|
||||
}
|
||||
|
||||
func parseSnoozeUntil(lower string, now time.Time) (time.Time, bool) {
|
||||
relative := regexp.MustCompile(`(?:через|на)\s+(\d{1,3})\s*(минуту|минуты|минут|час|часа|часов)`)
|
||||
if match := relative.FindStringSubmatch(lower); len(match) > 0 {
|
||||
value, err := strconv.Atoi(match[1])
|
||||
if err != nil || value < 1 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if strings.HasPrefix(match[2], "час") {
|
||||
if value > 72 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return now.Add(time.Duration(value) * time.Hour), true
|
||||
}
|
||||
if value > 24*60 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return now.Add(time.Duration(value) * time.Minute), true
|
||||
}
|
||||
if strings.Contains(lower, "позже") || strings.Contains(lower, "потом") {
|
||||
return now.Add(time.Hour), true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func candidateSummary(candidates []ResponseCandidate) string {
|
||||
parts := make([]string, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
text := strings.TrimSpace(candidate.Attention.Summary)
|
||||
if text == "" {
|
||||
text = "ожидание"
|
||||
}
|
||||
parts = append(parts, text)
|
||||
}
|
||||
if len(parts) > 3 {
|
||||
parts = parts[:3]
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func matchingCandidates(message string, candidates []ResponseCandidate) []ResponseCandidate {
|
||||
lower := strings.ToLower(message)
|
||||
var matched []ResponseCandidate
|
||||
for _, candidate := range candidates {
|
||||
summary := candidate.Attention.Summary
|
||||
if summary == "" {
|
||||
summary = candidate.Prospective.Summary
|
||||
}
|
||||
if summaryMatches(lower, summary) {
|
||||
matched = append(matched, candidate)
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
func summaryMatches(lowerMessage string, summary string) bool {
|
||||
for _, token := range strings.Fields(strings.ToLower(summary)) {
|
||||
token = strings.Trim(token, ".,!?:;()[]«»\"'")
|
||||
if len([]rune(token)) < 4 {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(lowerMessage, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func humanResponseTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return "позже"
|
||||
}
|
||||
return fmt.Sprintf("%02d.%02d в %02d:%02d", value.Day(), value.Month(), value.Hour(), value.Minute())
|
||||
}
|
||||
|
||||
func containsAny(value string, needles ...string) bool {
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(value, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
func TestInterpretHumanResponseCompletesSingleDeliveredReminder(t *testing.T) {
|
||||
result := interpretResponseForTest(t, "Готово", []ResponseCandidate{responseCandidate("a1", "p1", "позвонить врачу")})
|
||||
if !result.Handled || result.Response.Operation != continuity.ResponseComplete || result.Response.Outcome != continuity.ResponseOutcomeUserReportedComplete || !result.Response.UserReportedNotVerified {
|
||||
t.Fatalf("unexpected complete response: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretHumanResponseSnoozesSingleReminder(t *testing.T) {
|
||||
result := interpretResponseForTest(t, "Отложи на 10 минут", []ResponseCandidate{responseCandidate("a1", "p1", "проверить базу")})
|
||||
if !result.Handled || result.Response.Operation != continuity.ResponseSnooze || result.Response.Outcome != continuity.ResponseOutcomeSnoozed {
|
||||
t.Fatalf("unexpected snooze response: %#v", result)
|
||||
}
|
||||
if result.Response.SnoozeUntilUnixMillis != time.Unix(100, 0).Add(10*time.Minute).UnixMilli() {
|
||||
t.Fatalf("unexpected snooze time: %d", result.Response.SnoozeUntilUnixMillis)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretHumanResponseCancelsSingleReminder(t *testing.T) {
|
||||
result := interpretResponseForTest(t, "Отмени", []ResponseCandidate{responseCandidate("a1", "p1", "встретить курьера")})
|
||||
if !result.Handled || result.Response.Operation != continuity.ResponseCancel || result.Response.Outcome != continuity.ResponseOutcomeCancelled {
|
||||
t.Fatalf("unexpected cancel response: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretHumanResponseCancelsArmedProspectiveBeforeAttention(t *testing.T) {
|
||||
result := interpretResponseForTest(t, "Отмени встретить курьера", []ResponseCandidate{{
|
||||
Prospective: continuity.ProspectiveItem{ID: "p1", OwnerPrincipalID: "principal-1", ScopeID: "scope-1", Summary: "встретить курьера", State: continuity.ProspectiveArmed, Revision: 1},
|
||||
}})
|
||||
if !result.Handled || result.Response.TargetKind != continuity.ResponseTargetProspectiveItem || result.Response.TargetProspectiveID != "p1" || result.Response.Outcome != continuity.ResponseOutcomeCancelled {
|
||||
t.Fatalf("unexpected prospective cancel response: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretHumanResponseResolvesArmedProspectiveByNaturalSummaryReference(t *testing.T) {
|
||||
result := interpretResponseForTest(t, "Отмени встретить курьера", []ResponseCandidate{
|
||||
{Prospective: continuity.ProspectiveItem{ID: "p1", OwnerPrincipalID: "principal-1", ScopeID: "scope-1", Summary: "позвонить врачу", State: continuity.ProspectiveArmed, Revision: 1}},
|
||||
{Prospective: continuity.ProspectiveItem{ID: "p2", OwnerPrincipalID: "principal-1", ScopeID: "scope-1", Summary: "встретить курьера", State: continuity.ProspectiveArmed, Revision: 1}},
|
||||
})
|
||||
if !result.Handled || result.Response.TargetKind != continuity.ResponseTargetProspectiveItem || result.Response.TargetProspectiveID != "p2" || result.Response.Outcome != continuity.ResponseOutcomeCancelled {
|
||||
t.Fatalf("unexpected prospective natural target response: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretHumanResponseAmbiguousTargetAsksClarificationWithoutResolvedMutation(t *testing.T) {
|
||||
result := interpretResponseForTest(t, "Готово", []ResponseCandidate{responseCandidate("a1", "p1", "первое"), responseCandidate("a2", "p2", "второе")})
|
||||
if !result.Handled || result.Response.ResolutionState != continuity.ResponseAmbiguous || result.Response.Outcome != continuity.ResponseOutcomeClarificationNeeded || !strings.Contains(result.Reply, "несколько") {
|
||||
t.Fatalf("expected ambiguity clarification, got %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretHumanResponseResolvesAmbiguousTargetByNaturalSummaryReference(t *testing.T) {
|
||||
result := interpretResponseForTest(t, "Готово, проверить базу сделано", []ResponseCandidate{responseCandidate("a1", "p1", "позвонить врачу"), responseCandidate("a2", "p2", "проверить базу")})
|
||||
if !result.Handled || result.Response.ResolutionState != continuity.ResponseResolved || result.Response.TargetAttentionID != "a2" {
|
||||
t.Fatalf("expected natural summary target resolution, got %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretHumanResponseNoTargetAsksClarification(t *testing.T) {
|
||||
result := interpretResponseForTest(t, "Готово", nil)
|
||||
if !result.Handled || result.Response.ResolutionState != continuity.ResponseNoTarget || result.Response.Outcome != continuity.ResponseOutcomeClarificationNeeded {
|
||||
t.Fatalf("expected no-target clarification, got %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func interpretResponseForTest(t *testing.T, message string, candidates []ResponseCandidate) continuity.HumanResponseResult {
|
||||
t.Helper()
|
||||
result, err := InterpretHumanResponse(continuity.HumanResponseRequest{
|
||||
SourceUtteranceID: "utterance-1",
|
||||
SourceIntentID: "intent-1",
|
||||
Message: message,
|
||||
RequestedAtUnixMillis: time.Unix(100, 0).UnixMilli(),
|
||||
}, identity.PrincipalID("principal-1"), authority.ScopeID("scope-1"), continuity.HumanResponseID("response-1"), candidates, time.Unix(100, 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func responseCandidate(attentionID continuity.AttentionItemID, prospectiveID continuity.ProspectiveItemID, summary string) ResponseCandidate {
|
||||
return ResponseCandidate{
|
||||
Attention: continuity.AttentionItem{
|
||||
ID: attentionID,
|
||||
OwnerPrincipalID: "principal-1",
|
||||
ScopeID: "scope-1",
|
||||
SourceKind: continuity.AttentionSourceProspectiveReminder,
|
||||
SourceProspectiveID: prospectiveID,
|
||||
State: continuity.AttentionDelivered,
|
||||
Summary: summary,
|
||||
Revision: 1,
|
||||
},
|
||||
Prospective: continuity.ProspectiveItem{
|
||||
ID: prospectiveID,
|
||||
OwnerPrincipalID: "principal-1",
|
||||
ScopeID: "scope-1",
|
||||
Summary: summary,
|
||||
State: continuity.ProspectiveDelivered,
|
||||
Revision: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ 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")
|
||||
ErrInvalidAttention = errors.New("invalid attention item")
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
@@ -28,6 +30,50 @@ 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 BuildDueReminderAttention(item contract.ProspectiveItem, id contract.AttentionItemID, nowUnixMillis int64) (contract.AttentionItem, error) {
|
||||
if id == "" || item.ID == "" || item.OwnerPrincipalID == "" || item.ScopeID == "" ||
|
||||
strings.TrimSpace(item.Summary) == "" || item.TriggerAtUnixMillis < 1 || nowUnixMillis < 1 {
|
||||
return contract.AttentionItem{}, ErrInvalidAttention
|
||||
}
|
||||
if item.State != contract.ProspectiveDelivered && item.State != contract.ProspectiveRevalidating {
|
||||
return contract.AttentionItem{}, ErrInvalidAttention
|
||||
}
|
||||
return contract.AttentionItem{
|
||||
ID: id,
|
||||
OwnerPrincipalID: item.OwnerPrincipalID,
|
||||
ScopeID: item.ScopeID,
|
||||
SourceKind: contract.AttentionSourceProspectiveReminder,
|
||||
SourceProspectiveID: item.ID,
|
||||
Level: contract.AttentionDue,
|
||||
State: contract.AttentionPending,
|
||||
Summary: item.Summary,
|
||||
DueAtUnixMillis: item.TriggerAtUnixMillis,
|
||||
CreatedAtUnixMillis: nowUnixMillis,
|
||||
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 {
|
||||
|
||||
@@ -4,9 +4,17 @@ import (
|
||||
"context"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Save(context.Context, continuity.ProspectiveItem) error
|
||||
Load(context.Context, continuity.ProspectiveItemID) (continuity.ProspectiveItem, error)
|
||||
DueArmed(context.Context, int64, int) ([]continuity.ProspectiveItem, error)
|
||||
ClaimDueReminder(context.Context, continuity.ProspectiveItemID, continuity.ProspectiveRevision, continuity.AttentionItemID, int64) (continuity.ProspectiveItem, continuity.AttentionItem, error)
|
||||
PendingAttention(context.Context, int) ([]continuity.AttentionItem, error)
|
||||
MarkAttentionDelivering(context.Context, continuity.AttentionItemID, continuity.AttentionRevision) (continuity.AttentionItem, error)
|
||||
MarkAttentionDelivered(context.Context, continuity.AttentionItemID, continuity.AttentionRevision, continuity.CommittedActionIntentID, continuity.CommittedAuthorizedActionID, continuity.CommittedActionAttemptID, string, int64) (continuity.AttentionItem, error)
|
||||
MarkAttentionFailed(context.Context, continuity.AttentionItemID, continuity.AttentionRevision, continuity.CommittedActionIntentID, continuity.CommittedAuthorizedActionID, continuity.CommittedActionAttemptID, string) (continuity.AttentionItem, error)
|
||||
AttentionByOwner(context.Context, identity.PrincipalID, int) ([]continuity.AttentionItem, error)
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ func projectReferences(utterance interaction.HumanUtterance, projects []situatio
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(refs) == 0 && previousFocus != nil && strings.Contains(lower, "туда") {
|
||||
if len(refs) == 0 && previousFocus != nil && inheritedFocusAllowed(utterance.SpeechAct, utterance.Content) {
|
||||
ref, err := newProjectReference(utterance.ID, previousFocus.ProjectID, previousFocus.ProjectDisplayName, interaction.ProjectRolePrimary, situation.ProjectResolutionInheritedFocus, 700, millis)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -196,7 +196,17 @@ func requestedOutcome(message string, act interaction.SpeechAct) string {
|
||||
}
|
||||
|
||||
func inheritedFocusAllowed(act interaction.SpeechAct, message string) bool {
|
||||
return strings.Contains(strings.ToLower(message), "туда") && act != interaction.SpeechActUnknown
|
||||
lower := strings.ToLower(message)
|
||||
if act == interaction.SpeechActUnknown {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(lower, "туда") || strings.Contains(lower, "там") || strings.Contains(lower, "в этом проекте") {
|
||||
return true
|
||||
}
|
||||
if act == interaction.SpeechActQuestion || act == interaction.SpeechActRequestToInvestigate {
|
||||
return strings.Contains(lower, "база") || strings.Contains(lower, "сервер") || strings.Contains(lower, "почему тормозит") || strings.Contains(lower, "что там")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isAmbiguousDestructive(act interaction.SpeechAct, message string, projects []interaction.ProjectReference) bool {
|
||||
|
||||
@@ -73,6 +73,25 @@ func TestInterpretUtteranceInheritedFocusForThere(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectFocusIsContextualEvidenceNotGlobalMode(t *testing.T) {
|
||||
explicit := interpretForTest(t, "Сейчас работаем с УПО.", nil, "")
|
||||
if explicit.ActiveFocus == nil || explicit.ActiveFocus.ProjectID != "upo" {
|
||||
t.Fatalf("expected explicit UPO focus, got %#v", explicit.ActiveFocus)
|
||||
}
|
||||
focusedQuestion := interpretForTest(t, "Почему тормозит база?", activeFocus("upo", "УПО"), "")
|
||||
if len(focusedQuestion.Projects) != 1 || focusedQuestion.Projects[0].ProjectID != "upo" || focusedQuestion.Projects[0].Provenance != situation.ProjectResolutionInheritedFocus {
|
||||
t.Fatalf("database follow-up can use previous project as contextual evidence, got %#v", focusedQuestion.Projects)
|
||||
}
|
||||
unrelated := interpretForTest(t, "Помоги ответить на письмо бухгалтеру.", activeFocus("upo", "УПО"), "")
|
||||
if len(unrelated.Projects) != 0 {
|
||||
t.Fatalf("unrelated mail intent must not inherit UPO focus, got %#v", unrelated.Projects)
|
||||
}
|
||||
returned := interpretForTest(t, "Вернемся к УПО.", activeFocus("assistant", "Assistant"), "")
|
||||
if returned.ActiveFocus == nil || returned.ActiveFocus.ProjectID != "upo" {
|
||||
t.Fatalf("expected explicit return to UPO, got %#v", returned.ActiveFocus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretUtteranceMultiProjectReferenceRoles(t *testing.T) {
|
||||
result := interpretForTest(t, "Посмотри, как это сделано в Assistant, и сделай аналогично в УПО.", nil, "")
|
||||
if len(result.Projects) != 2 {
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user