diff --git a/adapters/postgres/capability_repository.go b/adapters/postgres/capability_repository.go index 8dc0d38..36a56f4 100644 --- a/adapters/postgres/capability_repository.go +++ b/adapters/postgres/capability_repository.go @@ -246,6 +246,53 @@ func (r *CapabilityRepository) SaveSkillProfile(ctx context.Context, value capab return tx.Commit(ctx) } +func (r *CapabilityRepository) LoadSkillProfile(ctx context.Context, id capability.SkillProfileID) (capability.SkillProfile, error) { + var value capability.SkillProfile + err := r.pool.QueryRow(ctx, `SELECT + p.skill_profile_id,p.revision,p.name,p.description,p.applicable_context,p.implementation_ref,p.implementation_version,p.status, + p.supporting_experience_refs,p.known_failure_experience_refs,p.confidence_millis,p.last_validated_unix_millis, + p.observed_failure_count,p.observed_correction_count,p.recorded_at_unix_millis + FROM capability.skill_profile_heads h + JOIN capability.skill_profiles p ON p.skill_profile_id=h.skill_profile_id AND p.revision=h.revision + WHERE p.skill_profile_id=$1`, id).Scan(&value.ID, &value.Revision, &value.Name, &value.Description, &value.ApplicableContext, + &value.ImplementationRef, &value.ImplementationVersion, &value.Status, &value.SupportingExperienceRefs, + &value.KnownFailureExperienceRefs, &value.ConfidenceMillis, &value.LastValidatedUnixMillis, + &value.ObservedFailureCount, &value.ObservedCorrectionCount, &value.RecordedAtUnixMillis) + if err != nil { + return capability.SkillProfile{}, fmt.Errorf("load skill profile: %w", err) + } + return value, nil +} + +func (r *CapabilityRepository) RecentSkillProfiles(ctx context.Context, limit int) ([]capability.SkillProfile, error) { + if limit < 1 || limit > 100 { + return nil, fmt.Errorf("invalid skill profile limit") + } + rows, err := r.pool.Query(ctx, `SELECT + p.skill_profile_id,p.revision,p.name,p.description,p.applicable_context,p.implementation_ref,p.implementation_version,p.status, + p.supporting_experience_refs,p.known_failure_experience_refs,p.confidence_millis,p.last_validated_unix_millis, + p.observed_failure_count,p.observed_correction_count,p.recorded_at_unix_millis + FROM capability.skill_profile_heads h + JOIN capability.skill_profiles p ON p.skill_profile_id=h.skill_profile_id AND p.revision=h.revision + ORDER BY h.updated_at DESC, p.skill_profile_id LIMIT $1`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var result []capability.SkillProfile + for rows.Next() { + var value capability.SkillProfile + if err := rows.Scan(&value.ID, &value.Revision, &value.Name, &value.Description, &value.ApplicableContext, + &value.ImplementationRef, &value.ImplementationVersion, &value.Status, &value.SupportingExperienceRefs, + &value.KnownFailureExperienceRefs, &value.ConfidenceMillis, &value.LastValidatedUnixMillis, + &value.ObservedFailureCount, &value.ObservedCorrectionCount, &value.RecordedAtUnixMillis); err != nil { + return nil, err + } + result = append(result, value) + } + return result, rows.Err() +} + func (r *CapabilityRepository) LoadBinding(ctx context.Context, id capability.CapabilityBindingID, revision int64) (capability.Binding, error) { var binding capability.Binding err := r.pool.QueryRow(ctx, `SELECT binding_id,capability_id,owner_principal_id,revision,state,https_origin,delivery_path,receipt_path, diff --git a/adapters/postgres/epistemics_repository.go b/adapters/postgres/epistemics_repository.go index 7f70dc1..ca3645c 100644 --- a/adapters/postgres/epistemics_repository.go +++ b/adapters/postgres/epistemics_repository.go @@ -31,6 +31,58 @@ func (r *EpistemicsRepository) SaveSystemExpectation(ctx context.Context, value return err } +func (r *EpistemicsRepository) RecentExperienceRecords(ctx context.Context, limit int) ([]epistemics.ExperienceRecord, error) { + if limit < 1 || limit > 100 { + return nil, fmt.Errorf("invalid experience limit") + } + rows, err := r.pool.Query(ctx, `SELECT + experience_id,source_kind,original_intent_id,source_utterance_id,context_refs,help_method,help_method_ref, + capability_implementation_ref,capability_usage_observation_ref,capability_gap_ref,skill_ref,expectation_id, + expected_outcome,actual_outcome,outcome_ref,outcome_detail,human_response_ref,correction_utterance_id, + verification_evidence,confidence_millis,recorded_at_unix_millis + FROM epistemics.experience_records ORDER BY recorded_at_unix_millis DESC, experience_id DESC LIMIT $1`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var result []epistemics.ExperienceRecord + for rows.Next() { + var value epistemics.ExperienceRecord + if err := rows.Scan(&value.ID, &value.SourceKind, &value.OriginalIntentID, &value.SourceUtteranceID, &value.ContextRefs, &value.HelpMethod, &value.HelpMethodRef, + &value.CapabilityImplementationRef, &value.CapabilityUsageObservationRef, &value.CapabilityGapRef, &value.SkillRef, &value.ExpectationID, + &value.ExpectedOutcome, &value.ActualOutcome, &value.OutcomeRef, &value.OutcomeDetail, &value.HumanResponseRef, &value.CorrectionUtteranceID, + &value.VerificationEvidence, &value.ConfidenceMillis, &value.RecordedAtUnixMillis); err != nil { + return nil, err + } + result = append(result, value) + } + return result, rows.Err() +} + +func (r *EpistemicsRepository) RecentReflectionCandidates(ctx context.Context, limit int) ([]epistemics.ReflectionCandidate, error) { + if limit < 1 || limit > 100 { + return nil, fmt.Errorf("invalid reflection limit") + } + rows, err := r.pool.Query(ctx, `SELECT + reflection_candidate_id,trigger,state,source_experience_id,original_intent_id,source_utterance_id, + summary,evidence_refs,requires_cognition,recorded_at_unix_millis,revision + FROM epistemics.reflection_candidates ORDER BY recorded_at_unix_millis DESC, reflection_candidate_id DESC LIMIT $1`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var result []epistemics.ReflectionCandidate + for rows.Next() { + var value epistemics.ReflectionCandidate + if err := rows.Scan(&value.ID, &value.Trigger, &value.State, &value.SourceExperienceID, &value.OriginalIntentID, &value.SourceUtteranceID, + &value.Summary, &value.EvidenceRefs, &value.RequiresCognition, &value.RecordedAtUnixMillis, &value.Revision); err != nil { + return nil, err + } + result = append(result, value) + } + return result, rows.Err() +} + func (r *EpistemicsRepository) SaveExperienceRecord(ctx context.Context, value epistemics.ExperienceRecord) error { _, err := r.pool.Exec(ctx, `INSERT INTO epistemics.experience_records ( experience_id, source_kind, original_intent_id, source_utterance_id, context_refs, help_method, help_method_ref, diff --git a/cmd/core-runtime/capability_chat.go b/cmd/core-runtime/capability_chat.go index c5759bc..61f99b2 100644 --- a/cmd/core-runtime/capability_chat.go +++ b/cmd/core-runtime/capability_chat.go @@ -144,10 +144,10 @@ func resolveChatCapability(ctx context.Context, repository *postgres.CapabilityR 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) { +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) (capability.CapabilityUsageObservationID, error) { id, err := ids.New() if err != nil { - return + return "", err } latency := now.Sub(started).Milliseconds() observation := capability.CapabilityUsageObservation{ @@ -164,7 +164,10 @@ func observeCapabilityUsage(ctx context.Context, repository *postgres.Capability LatencyMillis: &latency, ObservedAtUnixMillis: now.UnixMilli(), } - _ = repository.SaveCapabilityUsageObservation(ctx, observation) + if err := repository.SaveCapabilityUsageObservation(ctx, observation); err != nil { + return "", err + } + return observation.ID, nil } func buildReminderRequest(message string, session identity.CreatorSession, intent interaction.OriginalHumanIntent, utterance interaction.HumanUtterance, now time.Time) (continuity.ScheduleReminderRequest, error) { diff --git a/cmd/core-runtime/learning.go b/cmd/core-runtime/learning.go new file mode 100644 index 0000000..318bc82 --- /dev/null +++ b/cmd/core-runtime/learning.go @@ -0,0 +1,314 @@ +package main + +import ( + "context" + "strings" + "time" + + "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/interaction" + capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain" + epistemicsdomain "git.cin.su/m/Assistant/modules/epistemics/domain" +) + +const reminderResponseSkillID = capability.SkillProfileID("skill-obvious-reminder-response-v1") + +type learningEpistemicsStore interface { + SaveSystemExpectation(context.Context, epistemics.SystemExpectation) error + SaveExperienceRecord(context.Context, epistemics.ExperienceRecord) error + SaveReflectionCandidate(context.Context, epistemics.ReflectionCandidate) error +} + +type learningCapabilityStore interface { + LoadSkillProfile(context.Context, capability.SkillProfileID) (capability.SkillProfile, error) + SaveSkillProfile(context.Context, capability.SkillProfile) error +} + +type learningRecorder struct { + epistemics learningEpistemicsStore + capability capabilityLearningStore +} + +type capabilityLearningStore interface { + learningCapabilityStore +} + +type reminderResponseLearningInput struct { + Utterance interaction.HumanUtterance + Intent interaction.OriginalHumanIntent + Result continuity.HumanResponseResult + At time.Time +} + +type correctionLearningInput struct { + Utterance interaction.HumanUtterance + Intent interaction.OriginalHumanIntent + At time.Time +} + +type fastPathDeclineLearningInput struct { + Utterance interaction.HumanUtterance + Intent interaction.OriginalHumanIntent + Result continuity.HumanResponseResult + Reason capability.FastPathDeclineReason + At time.Time +} + +type capabilityLearningInput struct { + Utterance interaction.HumanUtterance + Intent interaction.OriginalHumanIntent + ImplementationID capability.CapabilityImplementationID + UsageObservation capability.CapabilityUsageObservationID + GapID capability.CapabilityGapID + Outcome capability.UsageOutcomeClass + OutcomeRef string + At time.Time +} + +func (r learningRecorder) recordReminderResponse(ctx context.Context, input reminderResponseLearningInput) { + if r.epistemics == nil || r.capability == nil || input.Intent.ID == "" || input.Utterance.ID == "" || input.Result.Response.ID == "" { + return + } + expectedEffect := "resolve obvious reminder continuation using deterministic target resolver" + if input.Result.Response.TargetProspectiveID != "" { + expectedEffect += " for prospective item " + string(input.Result.Response.TargetProspectiveID) + } + expectation := epistemics.SystemExpectation{ + OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID), + SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID), + ExpectedOutcome: epistemics.OutcomeSuccess, + ExpectedEffect: expectedEffect, + ConfidenceMillis: 850, + EvidenceBasis: []epistemics.EvidenceRef{"continuity:response-target-resolution"}, + RecordedAtUnixMillis: input.At.UnixMilli(), + } + expectation, err := epistemicsdomain.NewSystemExpectation(expectation) + if err != nil || r.epistemics.SaveSystemExpectation(ctx, expectation) != nil { + return + } + actual, confidence := outcomeForHumanResponse(input.Result.Response) + experience := epistemics.ExperienceRecord{ + SourceKind: epistemics.ExperienceSourceContinuity, + OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID), + SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID), + ContextRefs: contextRefs( + "continuity:human-response:"+string(input.Result.Response.ID), + "continuity:prospective:"+string(input.Result.Response.TargetProspectiveID), + "continuity:attention:"+string(input.Result.Response.TargetAttentionID), + ), + HelpMethod: epistemics.HelpMethodDeterministicFastPath, + HelpMethodRef: "continuity:human-response-target-resolver-v1", + SkillRef: epistemics.SkillRef(reminderResponseSkillID), + ExpectationID: expectation.ID, + ExpectedOutcome: expectation.ExpectedOutcome, + ActualOutcome: actual, + OutcomeRef: "continuity:human-response:" + string(input.Result.Response.ID), + OutcomeDetail: string(input.Result.Response.Operation) + " -> " + string(input.Result.Response.Outcome), + HumanResponseRef: epistemics.HumanResponseRef(input.Result.Response.ID), + VerificationEvidence: []epistemics.EvidenceRef{epistemics.EvidenceRef("human-response:" + string(input.Result.Response.Outcome))}, + ConfidenceMillis: confidence, + RecordedAtUnixMillis: input.At.UnixMilli(), + } + r.saveExperienceAndReflection(ctx, experience) + r.updateReminderSkill(ctx, experience, skillOutcomeForExperience(experience), false) +} + +func (r learningRecorder) recordCorrection(ctx context.Context, input correctionLearningInput) { + if r.epistemics == nil || input.Intent.ID == "" || input.Utterance.ID == "" { + return + } + experience := epistemics.ExperienceRecord{ + SourceKind: epistemics.ExperienceSourceInteraction, + OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID), + SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID), + HelpMethod: epistemics.HelpMethodClarification, + SkillRef: epistemics.SkillRef(reminderResponseSkillID), + ExpectedOutcome: epistemics.OutcomeSuccess, + ActualOutcome: epistemics.OutcomeUserCorrected, + OutcomeDetail: "human correction indicates prior interpretation or target resolution may be wrong", + CorrectionUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID), + VerificationEvidence: []epistemics.EvidenceRef{"interaction:human-correction"}, + ConfidenceMillis: 1000, + RecordedAtUnixMillis: input.At.UnixMilli(), + } + saved, ok := r.saveExperienceAndReflection(ctx, experience) + if ok { + r.updateReminderSkill(ctx, saved, capability.UsageFailed, true) + } +} + +func (r learningRecorder) recordFastPathDecline(ctx context.Context, input fastPathDeclineLearningInput) { + if r.epistemics == nil || input.Intent.ID == "" || input.Utterance.ID == "" { + return + } + experience, err := epistemicsdomain.FastPathDeclineExperience( + epistemics.OriginalIntentRef(input.Intent.ID), + epistemics.SourceUtteranceRef(input.Utterance.ID), + epistemics.SkillRef(reminderResponseSkillID), + string(outcomeForDecline(input.Reason)), + input.At.UnixMilli(), + ) + if err != nil { + return + } + if input.Result.Response.ID != "" { + experience.ContextRefs = contextRefs("continuity:response-candidate:" + string(input.Result.Response.Operation)) + } + saved, ok := r.saveExperienceAndReflection(ctx, experience) + if ok { + r.updateReminderSkill(ctx, saved, capability.UsageSkipped, false) + } +} + +func (r learningRecorder) recordCapabilityUse(ctx context.Context, input capabilityLearningInput) { + if r.epistemics == nil || input.Intent.ID == "" || input.Utterance.ID == "" { + return + } + actual := epistemics.OutcomeSuccess + switch input.Outcome { + case capability.UsageFailed: + actual = epistemics.OutcomeFailed + case capability.UsageSkipped: + actual = epistemics.OutcomeNotApplicable + case capability.UsageUnknown: + actual = epistemics.OutcomeUnknown + } + experience := epistemics.ExperienceRecord{ + SourceKind: epistemics.ExperienceSourceCapability, + OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID), + SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID), + HelpMethod: epistemics.HelpMethodCapabilityExecution, + CapabilityImplementationRef: epistemics.CapabilityImplementationRef(input.ImplementationID), + CapabilityUsageObservationRef: epistemics.CapabilityUsageObservationRef(input.UsageObservation), + CapabilityGapRef: epistemics.CapabilityGapRef(input.GapID), + ExpectedOutcome: epistemics.OutcomeSuccess, + ActualOutcome: actual, + OutcomeRef: input.OutcomeRef, + OutcomeDetail: "capability outcome " + string(input.Outcome), + VerificationEvidence: []epistemics.EvidenceRef{"capability:usage-observation"}, + ConfidenceMillis: 900, + RecordedAtUnixMillis: input.At.UnixMilli(), + } + r.saveExperienceAndReflection(ctx, experience) +} + +func (r learningRecorder) saveExperienceAndReflection(ctx context.Context, value epistemics.ExperienceRecord) (epistemics.ExperienceRecord, bool) { + value, err := epistemicsdomain.NewExperienceRecord(value) + if err != nil { + return epistemics.ExperienceRecord{}, false + } + if err := r.epistemics.SaveExperienceRecord(ctx, value); err != nil { + return epistemics.ExperienceRecord{}, false + } + if candidate, ok, err := epistemicsdomain.ReflectionForExperience(value); err == nil && ok { + _ = r.epistemics.SaveReflectionCandidate(ctx, candidate) + } + return value, true +} + +func (r learningRecorder) updateReminderSkill(ctx context.Context, experience epistemics.ExperienceRecord, outcome capability.UsageOutcomeClass, corrected bool) { + if r.capability == nil || experience.ID == "" { + return + } + profile, err := r.capability.LoadSkillProfile(ctx, reminderResponseSkillID) + if err != nil { + return + } + next, err := capabilitydomain.ObserveSkillOutcome(profile, capability.SkillOutcomeObservation{ + ExperienceRef: capability.SkillEvidenceRef("epistemics:experience:" + string(experience.ID)), + Outcome: outcome, + UserCorrected: corrected, + ObservedAtUnixMillis: experience.RecordedAtUnixMillis, + }) + if err != nil { + return + } + if err := r.capability.SaveSkillProfile(ctx, next); err != nil { + return + } +} + +func outcomeForHumanResponse(response continuity.HumanResponse) (epistemics.OutcomeClass, int64) { + if response.ResolutionState != continuity.ResponseResolved { + switch response.ResolutionState { + case continuity.ResponseAmbiguous: + return epistemics.OutcomeConflict, 850 + case continuity.ResponseNoTarget: + return epistemics.OutcomeOutOfDistribution, 850 + default: + return epistemics.OutcomeUnexpected, 700 + } + } + switch response.Outcome { + case continuity.ResponseOutcomeUserReportedComplete, continuity.ResponseOutcomeSnoozed, continuity.ResponseOutcomeCancelled, continuity.ResponseOutcomeDismissed: + return epistemics.OutcomeSuccess, 950 + case continuity.ResponseOutcomeClarificationNeeded: + return epistemics.OutcomeConflict, 850 + default: + return epistemics.OutcomeUnknown, 500 + } +} + +func skillOutcomeForExperience(experience epistemics.ExperienceRecord) capability.UsageOutcomeClass { + switch experience.ActualOutcome { + case epistemics.OutcomeSuccess, epistemics.OutcomePartialSuccess: + return capability.UsageSucceeded + case epistemics.OutcomeFailed, epistemics.OutcomeUnexpected, epistemics.OutcomeUserCorrected: + return capability.UsageFailed + case epistemics.OutcomeUnknown: + return capability.UsageUnknown + default: + return capability.UsageSkipped + } +} + +func outcomeForDecline(reason capability.FastPathDeclineReason) epistemics.OutcomeClass { + switch reason { + case capability.FastPathLowConfidence: + return epistemics.OutcomeLowConfidence + case capability.FastPathOutOfDistribution: + return epistemics.OutcomeOutOfDistribution + case capability.FastPathConflict: + return epistemics.OutcomeConflict + case capability.FastPathStale: + return epistemics.OutcomeStale + default: + return epistemics.OutcomeNotApplicable + } +} + +func contextRefs(values ...string) []epistemics.ContextRef { + refs := make([]epistemics.ContextRef, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || strings.HasSuffix(value, ":") { + continue + } + refs = append(refs, epistemics.ContextRef(value)) + } + return refs +} + +func looksLikeUnsupportedContinuation(message string, result continuity.HumanResponseResult) bool { + lower := strings.ToLower(strings.TrimSpace(message)) + if result.Handled { + return false + } + if result.Response.Operation == continuity.ResponseContinue || result.Response.Operation == continuity.ResponseReject { + return true + } + return strings.Contains(lower, "это") && (strings.Contains(lower, "напомин") || strings.Contains(lower, "перенеси") || strings.Contains(lower, "закрой") || strings.Contains(lower, "передвинь")) +} + +func declineReasonForResponse(result continuity.HumanResponseResult) capability.FastPathDeclineReason { + switch result.Response.Operation { + case continuity.ResponseReject: + return capability.FastPathConflict + case continuity.ResponseContinue: + return capability.FastPathNotApplicable + default: + return capability.FastPathOutOfDistribution + } +} diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index 93b70f3..a29f8a1 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -68,6 +68,19 @@ func main() { log.Fatal(err) } defer capabilityRepository.Close() + epistemicsPassword, err := readCredentialRefSecret("EPISTEMICS_POSTGRES_PASSWORD", "EPISTEMICS_POSTGRES_CREDENTIAL_REF") + if err != nil { + log.Fatal(err) + } + epistemicsUser := os.Getenv("EPISTEMICS_POSTGRES_USER") + if epistemicsUser == "" { + epistemicsUser = "epistemics_role" + } + epistemicsRepository, err := postgres.OpenEpistemics(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: epistemicsUser, Password: epistemicsPassword}) + if err != nil { + log.Fatal(err) + } + defer epistemicsRepository.Close() identityRepository, err := postgres.OpenIdentity(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("IDENTITY_POSTGRES_USER"), Password: os.Getenv("IDENTITY_POSTGRES_PASSWORD")}) if err != nil { log.Fatal(err) @@ -94,6 +107,7 @@ func main() { if err != nil { log.Fatal(err) } + learning := learningRecorder{epistemics: epistemicsRepository, capability: capabilityRepository} mux := http.NewServeMux() authenticateCreator := func(w http.ResponseWriter, r *http.Request) (identity.CreatorSession, bool) { session, err := identitydomain.ValidateCreatorSession(sessionKey, bearerToken(r), time.Now()) @@ -172,6 +186,42 @@ func main() { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(status) }) + mux.HandleFunc("GET /v1/creator/learning/recent", func(w http.ResponseWriter, r *http.Request) { + if _, ok := authenticateCreator(w, r); !ok { + return + } + limit := 10 + if raw := r.URL.Query().Get("limit"); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil || parsed < 1 || parsed > 50 { + http.Error(w, "invalid learning limit", http.StatusBadRequest) + return + } + limit = parsed + } + experiences, err := epistemicsRepository.RecentExperienceRecords(r.Context(), limit) + if err != nil { + http.Error(w, "learning experience unavailable", http.StatusServiceUnavailable) + return + } + reflections, err := epistemicsRepository.RecentReflectionCandidates(r.Context(), limit) + if err != nil { + http.Error(w, "learning reflection unavailable", http.StatusServiceUnavailable) + return + } + skills, err := capabilityRepository.RecentSkillProfiles(r.Context(), limit) + if err != nil { + http.Error(w, "learning skills unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(struct { + Experiences []epistemics.ExperienceRecord + Reflections []epistemics.ReflectionCandidate + Skills []capability.SkillProfile + }{Experiences: experiences, Reflections: reflections, Skills: skills}) + }) mux.HandleFunc("POST /v1/creator/chat/messages", func(w http.ResponseWriter, r *http.Request) { session, ok := authenticateCreator(w, r) if !ok { @@ -245,6 +295,9 @@ func main() { Message: utterance.Content, RequestedAtUnixMillis: now.UnixMilli(), }) + if semantic.Interpretation.SpeechAct == interaction.SpeechActCorrection { + learning.recordCorrection(r.Context(), correctionLearningInput{Utterance: utterance, Intent: intent, At: now}) + } activeFocusName := "" activeFocusProvenance := situation.ProjectResolutionProvenance("") if semantic.ActiveFocus != nil { @@ -270,6 +323,9 @@ func main() { if responseErr == nil && responseResult.Handled { planningReply = responseResult.Reply needsCognition = false + learning.recordReminderResponse(r.Context(), reminderResponseLearningInput{Utterance: utterance, Intent: intent, Result: responseResult, At: now}) + } else if responseErr == nil && looksLikeUnsupportedContinuation(utterance.Content, responseResult) { + learning.recordFastPathDecline(r.Context(), fastPathDeclineLearningInput{Utterance: utterance, Intent: intent, Result: responseResult, Reason: declineReasonForResponse(responseResult), At: now}) } planningInput := work.PlanningInput{ ConversationID: work.ConversationRef(conversation.ID), @@ -320,10 +376,12 @@ func main() { 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) + usageID, _ := observeCapabilityUsage(r.Context(), capabilityRepository, resolution.Eligible[0], resolution.Requirement, capability.UsageSucceeded, string(result.Item.ID), started, finished) + learning.recordCapabilityUse(r.Context(), capabilityLearningInput{Utterance: utterance, Intent: intent, ImplementationID: resolution.Eligible[0].Implementation.ID, UsageObservation: usageID, Outcome: capability.UsageSucceeded, OutcomeRef: "continuity:prospective:" + string(result.Item.ID), At: finished}) } else { planningReply = "Scheduling capability сейчас недоступна: напоминание не создано. Я не буду притворяться, что действие выполнено." - observeCapabilityUsage(r.Context(), capabilityRepository, resolution.Eligible[0], resolution.Requirement, capability.UsageFailed, "continuity-reminder-unavailable", started, finished) + usageID, _ := observeCapabilityUsage(r.Context(), capabilityRepository, resolution.Eligible[0], resolution.Requirement, capability.UsageFailed, "continuity-reminder-unavailable", started, finished) + learning.recordCapabilityUse(r.Context(), capabilityLearningInput{Utterance: utterance, Intent: intent, ImplementationID: resolution.Eligible[0].Implementation.ID, UsageObservation: usageID, Outcome: capability.UsageFailed, OutcomeRef: "continuity-reminder-unavailable", At: finished}) } } else { planningReply = "Мне нужно уточнить время напоминания. Например: «Напомни завтра в 10 позвонить врачу»." @@ -332,6 +390,7 @@ func main() { } else if resolution.Gap != nil { capabilityGapID = resolution.Gap.ID planningReply = "Сейчас scheduling capability недоступна, поэтому напоминание не создано. Я зафиксировал capability gap." + learning.recordCapabilityUse(r.Context(), capabilityLearningInput{Utterance: utterance, Intent: intent, GapID: resolution.Gap.ID, Outcome: capability.UsageSkipped, OutcomeRef: "capability:gap:" + string(resolution.Gap.ID), At: now}) } needsCognition = false } @@ -344,6 +403,7 @@ func main() { } else if resolution.Gap != nil { capabilityGapID = resolution.Gap.ID planningReply = "Сейчас у меня ещё не подключена возможность отправлять почту. Я зафиксировал capability gap, без ложного выполнения." + learning.recordCapabilityUse(r.Context(), capabilityLearningInput{Utterance: utterance, Intent: intent, GapID: resolution.Gap.ID, Outcome: capability.UsageSkipped, OutcomeRef: "capability:gap:" + string(resolution.Gap.ID), At: now}) } needsCognition = false } diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go index 4844893..70bcb97 100644 --- a/cmd/creator-web/server.go +++ b/cmd/creator-web/server.go @@ -536,6 +536,8 @@ func creatorRoute(method, path string) (routeTarget, string, bool) { if validID(id) { return targetCore, "/v1/creator/capabilities/" + id + "/health", true } + case method == http.MethodGet && path == "/api/learning/recent": + return targetCore, "/v1/creator/learning/recent", true case method == http.MethodGet && strings.HasPrefix(path, "/api/prospective-items/"): id := strings.TrimPrefix(path, "/api/prospective-items/") if validID(id) { diff --git a/cmd/creator-web/static/assets/console.css b/cmd/creator-web/static/assets/console.css index 9fe0a97..8dbd237 100644 --- a/cmd/creator-web/static/assets/console.css +++ b/cmd/creator-web/static/assets/console.css @@ -141,9 +141,11 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px .register-empty { margin: 0; padding: 22px 18px; color: var(--faint); font-size: 11px; } .activity-panel { margin-bottom: 18px; border: 1px solid var(--line); background: rgba(13, 20, 17, .9); } +.learning-panel { margin-bottom: 18px; border: 1px solid rgba(120,198,163,.3); background: linear-gradient(135deg, rgba(22,31,27,.92), rgba(10,16,14,.94)); box-shadow: inset 3px 0 0 rgba(120,198,163,.38); } .activity-panel > header { min-height: 66px; padding: 13px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); } -.activity-panel header p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; } -.activity-panel h2 { margin: 0; font: 400 22px/1 var(--serif); } +.learning-panel > header { min-height: 66px; padding: 13px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); } +.activity-panel header p, .learning-panel header p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; } +.activity-panel h2, .learning-panel h2 { margin: 0; font: 400 22px/1 var(--serif); } .activity-tools { display: flex; align-items: center; gap: 12px; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .1em; } .activity-tools .icon-button { color: var(--muted); font-size: 18px; } .activity-list { max-height: 300px; overflow: auto; scrollbar-color: var(--faint) transparent; } @@ -167,6 +169,23 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px .activity-reconcile:disabled { opacity: .55; cursor: wait; } .activity-error { color: var(--bad); } +.learning-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); } +.learning-grid article { min-width: 0; border-right: 1px solid var(--line-soft); } +.learning-grid article:last-child { border-right: 0; } +.learning-grid h3 { margin: 0; padding: 13px 18px 10px; color: var(--signal); font: 600 10px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; border-bottom: 1px solid var(--line-soft); } +.learning-list { max-height: 238px; overflow: auto; scrollbar-color: var(--faint) transparent; } +.learning-list[aria-busy="true"] { opacity: .72; } +.learning-row { min-height: 66px; padding: 12px 18px; display: grid; gap: 6px; border-bottom: 1px solid var(--line-soft); } +.learning-row:last-child { border-bottom: 0; } +.learning-row:hover { background: rgba(120,198,163,.05); } +.learning-row strong { color: var(--ink); font: 600 10px/1.35 var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.learning-row span { color: var(--muted); font: 10px/1.35 var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.learning-row time { color: var(--faint); font: 9px/1.35 var(--mono); } +.learning-state { width: fit-content; padding: 5px 8px; border: 1px solid currentColor; border-radius: 100px; color: var(--faint); font: 600 8px/1 var(--mono); letter-spacing: .06em; } +.learning-state[data-state="SUCCESS"], .learning-state[data-state="VALIDATED"] { color: var(--good); } +.learning-state[data-state="USER_CORRECTED"], .learning-state[data-state="FAILED"], .learning-state[data-state="DEGRADED"], .learning-state[data-state="SUSPENDED"] { color: var(--bad); } +.learning-state[data-state="CONFLICT"], .learning-state[data-state="OUT_OF_DISTRIBUTION"], .learning-state[data-state="OPEN"], .learning-state[data-state="EVALUATING"] { color: var(--warn); } + .data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } .data-card { min-width: 0; padding: 24px; border: 1px solid var(--line); background: linear-gradient(145deg, rgba(22, 31, 27, .94), rgba(13, 20, 17, .98)); box-shadow: 0 10px 40px rgba(0,0,0,.12); animation: reveal .55s both; } .task-card { animation-delay: .08s; } @@ -281,7 +300,11 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px .advisor-result { grid-template-columns: 1fr; gap: 10px; } .register-panel > header { align-items: start; } .activity-panel > header { align-items: flex-start; } + .learning-panel > header { align-items: flex-start; } .activity-tools { align-items: flex-end; flex-direction: column-reverse; } + .learning-grid { grid-template-columns: 1fr; } + .learning-grid article { border-right: 0; border-bottom: 1px solid var(--line-soft); } + .learning-grid article:last-child { border-bottom: 0; } .activity-row { padding: 14px 16px; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 12px; } .activity-state { grid-column: 2; grid-row: 1; } .activity-operation { grid-column: 1; grid-row: 1; } diff --git a/cmd/creator-web/static/assets/console.js b/cmd/creator-web/static/assets/console.js index 846a9db..9ccec82 100644 --- a/cmd/creator-web/static/assets/console.js +++ b/cmd/creator-web/static/assets/console.js @@ -84,7 +84,10 @@ async function showConsole() { await loadAdvisoryPolicy(); const context = saveContext(); if (context["session-id"] && context["workspace-id"]) loadDashboard(); - else loadActionHistory(); + else { + loadActionHistory(); + loadLearning(); + } } function endSession(message = "Сессия завершена.") { @@ -147,6 +150,7 @@ contextForm.addEventListener("submit", (event) => { byId("refresh-all").addEventListener("click", loadDashboard); byId("refresh-actions").addEventListener("click", loadActionHistory); +byId("refresh-learning").addEventListener("click", loadLearning); byId("logout").addEventListener("click", () => endSession()); advisoryForm.addEventListener("submit", async (event) => { @@ -234,6 +238,7 @@ async function loadDashboard() { } work.push(loadProspectiveRegister(context["prospective-id"])); work.push(loadActionHistory()); + work.push(loadLearning()); if (context["capability-id"]) work.push(loadCapability(context["capability-id"])); else setSignal("capability", "не выбрана", "idle"); @@ -241,6 +246,106 @@ async function loadDashboard() { setText("last-update", `Обновлено ${new Intl.DateTimeFormat("ru-RU", {hour: "2-digit", minute: "2-digit", second: "2-digit"}).format(new Date())}`); } +async function loadLearning() { + const experienceList = byId("experience-list"); + const reflectionList = byId("reflection-list"); + const skillList = byId("skill-list"); + [experienceList, reflectionList, skillList].forEach((list) => { + list.setAttribute("aria-busy", "true"); + list.replaceChildren(); + const loading = document.createElement("p"); + loading.className = "register-empty"; + loading.textContent = "Читаю learning evidence…"; + list.append(loading); + }); + try { + const result = await request("/api/learning/recent?limit=8"); + const experiences = Array.isArray(result.Experiences) ? result.Experiences : []; + const reflections = Array.isArray(result.Reflections) ? result.Reflections : []; + const skills = Array.isArray(result.Skills) ? result.Skills : []; + setText("learning-count", `${experiences.length + reflections.length + skills.length} СИГНАЛОВ`); + renderLearningList(experienceList, experiences, createExperienceRow, "Experience пока не записан."); + renderLearningList(reflectionList, reflections, createReflectionRow, "ReflectionCandidate пока нет."); + renderLearningList(skillList, skills, createSkillRow, "SkillProfile пока не загружен."); + } catch (error) { + [experienceList, reflectionList, skillList].forEach((list) => { + list.replaceChildren(); + const failed = document.createElement("p"); + failed.className = "register-empty activity-error"; + failed.textContent = error.message; + list.append(failed); + }); + setText("learning-count", "ОШИБКА"); + } finally { + [experienceList, reflectionList, skillList].forEach((list) => list.setAttribute("aria-busy", "false")); + } +} + +function renderLearningList(list, items, rowFactory, emptyText) { + list.replaceChildren(); + if (items.length === 0) { + const empty = document.createElement("p"); + empty.className = "register-empty"; + empty.textContent = emptyText; + list.append(empty); + return; + } + items.forEach((item) => list.append(rowFactory(item))); +} + +function createExperienceRow(item) { + const row = document.createElement("article"); + row.className = "learning-row"; + const state = document.createElement("em"); + state.className = "learning-state"; + state.dataset.state = item.ActualOutcome || "UNKNOWN"; + state.textContent = item.ActualOutcome || "UNKNOWN"; + const title = document.createElement("strong"); + title.textContent = item.HelpMethod || "help method"; + const detail = document.createElement("span"); + detail.textContent = item.OutcomeDetail || item.OutcomeRef || "evidence recorded"; + const time = document.createElement("time"); + time.textContent = item.RecordedAtUnixMillis ? new Date(item.RecordedAtUnixMillis).toLocaleString("ru-RU") : "время неизвестно"; + row.append(state, title, detail, time); + return row; +} + +function createReflectionRow(item) { + const row = document.createElement("article"); + row.className = "learning-row"; + const state = document.createElement("em"); + state.className = "learning-state"; + state.dataset.state = item.State || "OPEN"; + state.textContent = item.Trigger || "REFLECTION"; + const title = document.createElement("strong"); + title.textContent = item.State || "OPEN"; + const detail = document.createElement("span"); + detail.textContent = item.Summary || "reflection candidate"; + const time = document.createElement("time"); + time.textContent = item.RecordedAtUnixMillis ? new Date(item.RecordedAtUnixMillis).toLocaleString("ru-RU") : "время неизвестно"; + row.append(state, title, detail, time); + return row; +} + +function createSkillRow(item) { + const row = document.createElement("article"); + row.className = "learning-row"; + const state = document.createElement("em"); + state.className = "learning-state"; + state.dataset.state = item.Status || "UNKNOWN"; + state.textContent = item.Status || "UNKNOWN"; + const title = document.createElement("strong"); + title.textContent = item.Name || "skill profile"; + const detail = document.createElement("span"); + const support = Array.isArray(item.SupportingExperienceRefs) ? item.SupportingExperienceRefs.length : 0; + const failures = Array.isArray(item.KnownFailureExperienceRefs) ? item.KnownFailureExperienceRefs.length : 0; + detail.textContent = `confidence ${item.ConfidenceMillis ?? "—"} · +${support} / -${failures}`; + const time = document.createElement("time"); + time.textContent = item.RecordedAtUnixMillis ? new Date(item.RecordedAtUnixMillis).toLocaleString("ru-RU") : "время неизвестно"; + row.append(state, title, detail, time); + return row; +} + async function loadActionHistory() { const list = byId("action-history"); list.setAttribute("aria-busy", "true"); diff --git a/cmd/creator-web/static/index.html b/cmd/creator-web/static/index.html index c8a02e5..86e9619 100644 --- a/cmd/creator-web/static/index.html +++ b/cmd/creator-web/static/index.html @@ -143,6 +143,30 @@ +
+
+

Learning / evidence

Опыт и reflection

+
+ 0 СИГНАЛОВ + +
+
+
+
+

Experience

+

Опыт ещё не загружен.

+
+
+

Reflection

+

Reflection ещё не загружен.

+
+
+

Skills

+

Skill profile ещё не загружен.

+
+
+
+
01

Situation

Активный фокус

НЕ ЗАГРУЖЕН
diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index d665d45..c90aef1 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -17,6 +17,7 @@ services: WORK_DB_PASSWORD: ${WORK_DB_PASSWORD} CAPABILITY_DB_PASSWORD: ${CAPABILITY_DB_PASSWORD} COMMIT_DB_PASSWORD: ${COMMIT_DB_PASSWORD} + EPISTEMICS_DB_PASSWORD_FILE: /run/secrets/epistemics_db_password INTERACTION_DB_PASSWORD_FILE: /run/secrets/interaction_db_password volumes: - assistant_pg:/var/lib/postgresql @@ -72,6 +73,9 @@ services: WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD} CAPABILITY_POSTGRES_USER: capability_role CAPABILITY_POSTGRES_PASSWORD: ${CAPABILITY_DB_PASSWORD} + EPISTEMICS_POSTGRES_USER: epistemics_role + EPISTEMICS_POSTGRES_CREDENTIAL_REF: credential://domovoy/epistemics/db-password/v1 + EPISTEMICS_POSTGRES_PASSWORD_FILE: /run/secrets/epistemics_db_password INTERACTION_POSTGRES_USER: interaction_role INTERACTION_POSTGRES_CREDENTIAL_REF: credential://domovoy/interaction/db-password/v1 INTERACTION_POSTGRES_PASSWORD_FILE: /run/secrets/interaction_db_password diff --git a/deploy/images/002_owner_passwords.sh b/deploy/images/002_owner_passwords.sh index 5184b99..40a7053 100644 --- a/deploy/images/002_owner_passwords.sh +++ b/deploy/images/002_owner_passwords.sh @@ -18,6 +18,13 @@ SQL psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v capability_password="$CAPABILITY_DB_PASSWORD" <<'SQL' ALTER ROLE capability_role PASSWORD :'capability_password'; SQL +if [ "${EPISTEMICS_DB_PASSWORD_FILE:-}" != "" ]; then + EPISTEMICS_DB_PASSWORD="$(tr -d '\r\n' < "$EPISTEMICS_DB_PASSWORD_FILE")" +fi +: "${EPISTEMICS_DB_PASSWORD:?EPISTEMICS_DB_PASSWORD or EPISTEMICS_DB_PASSWORD_FILE is required}" +psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v epistemics_password="$EPISTEMICS_DB_PASSWORD" <<'SQL' +ALTER ROLE epistemics_role PASSWORD :'epistemics_password'; +SQL psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v commit_password="$COMMIT_DB_PASSWORD" <<'SQL' ALTER ROLE commit_role PASSWORD :'commit_password'; SQL diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index fd28b0d..39f90fa 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000067-experience-reflection-skill-evolution.json", - "task_id": "DEV-000067", + "task_file": "dev/tasks/DEV-000068-runtime-experience-reflection-loop.json", + "task_id": "DEV-000068", "task_revision": 1 } diff --git a/dev/tasks/DEV-000068-runtime-experience-reflection-loop.json b/dev/tasks/DEV-000068-runtime-experience-reflection-loop.json new file mode 100644 index 0000000..eb0d7fa --- /dev/null +++ b/dev/tasks/DEV-000068-runtime-experience-reflection-loop.json @@ -0,0 +1,138 @@ +{ + "task_id": "DEV-000068", + "task_revision": 1, + "architecture_revision": "ADR-0016", + "base_repo_revision": "67ff1f73cddfd94f9f5745acc87f997aed49630a", + "risk_tier": "E", + "intent": "Activate the first production runtime Experience and Reflection loop for meaningful /chat, HumanResponse, capability and fast-path outcomes without creating a LearningManager.", + "requirement_ids": [ + "ADR-0016", + "INV-OWN-001", + "INV-EPI-001", + "Runtime Experience capture", + "Runtime ReflectionCandidate triggers", + "SkillEvidence from real reminder response outcomes", + "Fast-path decline returns to reasoning/clarification" + ], + "allowed_change_scope": { + "modules": [ + "epistemics", + "capability", + "interaction", + "continuity" + ], + "paths": [ + "contracts/epistemics/**", + "contracts/capability/**", + "modules/epistemics/**", + "modules/capability/**", + "adapters/postgres/**", + "database/migrations/**", + "cmd/core-runtime/**", + "cmd/creator-web/**", + "cmd/continuity-runtime/**", + "deploy/docker-compose.prod.yml", + "deploy/images/002_owner_passwords.sh", + "deploy/windows/**", + "tests/owner_acl/**", + "tests/cognitive_advisory/**", + "docs/PROJECT_STATUS.md", + "dev/ACTIVE_TASK.json", + "dev/tasks/DEV-000068-runtime-experience-reflection-loop.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, + "new_runtime_secret_file_for_existing_owner": "epistemics_role database password for core-runtime" + }, + "contracts": { + "consume": [ + "Interaction HumanUtterance / OriginalHumanIntent / Correction refs", + "Continuity HumanResponse / Attention / ProspectiveItem refs", + "Capability CapabilityUsageObservation / CapabilityGap / SkillProfile", + "Epistemics SystemExpectation / ExperienceRecord / ReflectionCandidate" + ], + "may_change": [ + "contracts/epistemics@v1", + "contracts/capability@v1" + ] + }, + "semantic_owner": "epistemics", + "canonical_state": [ + "SystemExpectation", + "ExperienceRecord", + "ReflectionCandidate", + "SkillProfile" + ], + "state_transitions": [ + "meaningful chat help path -> SystemExpectation before outcome", + "HumanResponse resolved COMPLETE/SNOOZE/CANCEL -> positive ExperienceRecord and SkillProfile supporting evidence", + "Human correction -> USER_CORRECTED ExperienceRecord and ReflectionCandidate", + "ambiguous/no-target/unsupported fast path -> decline/clarification ExperienceRecord and ReflectionCandidate", + "repeated failure/correction evidence -> SkillProfile DEGRADED/SUSPENDED according to deterministic policy" + ], + "side_effects_and_privileges": [ + "No external mutation is introduced", + "No model output can directly mutate canonical state, Authority, source code, deployment or skill lifecycle", + "Core runtime receives only the existing Epistemics owner database credential through secret-file/CredentialRef mechanism", + "Creator Web remains a proxy/UI and receives no direct database credential", + "Docker-test remains prohibited" + ], + "persistence_and_migrations": [ + "no new canonical owner", + "no cross-owner FK", + "no cross-owner SQL join dependency", + "reuse DEV-000067 tables with optional indexes/projections only if needed" + ], + "failure_retry_restart": [ + "Experience/reflection capture is append-only and idempotent by generated IDs", + "A failed learning write must not pretend success; production acceptance verifies rows", + "Fast-path decline is valid safety behavior and does not mutate target state" + ], + "assumptions": [ + "DEV-000067 production foundation is active", + "ADR-0016 is sufficient; no new architecture owner is required", + "V1 uses deterministic reflection triggers; cognitive reflection remains optional candidate generation only" + ], + "acceptance_scenarios": [ + "Create reminder then complete it -> positive ExperienceRecord and SkillProfile supporting evidence", + "Create ambiguous targets and correct Domovoy -> USER_CORRECTED ExperienceRecord and ReflectionCandidate", + "Unsupported/uncertain response phrasing -> fast path declines and asks clarification/falls back without guessed mutation", + "Operator can inspect recent ExperienceRecords, ReflectionCandidates and SkillProfile status", + "Human chat does not expose internal learning IDs", + "LLM-free operation 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 learning acceptance A/B/C", + "production health", + "production /chat verification" + ], + "terminal_gap_states": [ + "BLOCKED_BY_ARCHITECTURE_GAP", + "BLOCKED_BY_SPEC_GAP", + "BLOCKED_BY_MISSING_CAPABILITY", + "BLOCKED_BY_CONFLICT" + ] +}