diff --git a/cmd/cognitive-worker/gateway.go b/cmd/cognitive-worker/gateway.go index 008e0e9..a9b105c 100644 --- a/cmd/cognitive-worker/gateway.go +++ b/cmd/cognitive-worker/gateway.go @@ -184,7 +184,18 @@ func builtinAdvisory(request cognitive.GatewayAdvisoryRequest) cognitive.Gateway func (g cognitiveGateway) invokeBuiltinAdvisory(ctx context.Context, request cognitive.GatewayAdvisoryRequest, maxOutputBytes int) cognitive.GatewayAdvisoryResponse { if g.builtinLocal != nil && g.builtinLocal.Provider != nil { invocation := cognitive.AdvisoryRequest{Version: request.Version, RequestID: request.RequestID, ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: request.Message, Context: request.Context, Locale: request.Locale, MaxOutputBytes: maxOutputBytes, ProviderBindingID: "builtin-local-binding-v1", ProviderBindingRevision: 1, ProviderInstanceID: "builtin-local-lifeboat-v1", ModelDescriptorID: "builtin-local-model-v1", ProviderModelID: "domovoy-lifeboat"} + started := g.now() response, err := g.builtinLocal.Provider.Advise(ctx, invocation) + if response.RequestID == "" { + response.RequestID = request.RequestID + } + if response.Version == "" { + response.Version = request.Version + } + if err != nil { + response = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: "builtin-local-binding-v1", ProviderBindingRevision: 1, FinishReason: cognitive.FinishInvalidProviderResponse} + } + g.observeBuiltin(ctx, g.builtinLocal, response, started) if err == nil && response.FinishReason == cognitive.FinishCompleted && strings.TrimSpace(response.AdvisoryText) != "" { return cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, AdvisoryText: response.AdvisoryText, ReferencedExcerptIDs: response.ReferencedExcerptIDs, FinishReason: response.FinishReason, Usage: response.Usage} } @@ -237,6 +248,21 @@ func (g cognitiveGateway) observe(ctx context.Context, selected capability.Resol } } +// observeBuiltin gives the always-available local provider the same evidence +// path as a resolved binding. The identity is stable and contains references +// only; no endpoint, credential, prompt, or response text is persisted here. +func (g cognitiveGateway) observeBuiltin(ctx context.Context, registration *providerRegistration, response cognitive.AdvisoryResponse, started time.Time) { + if registration == nil { + return + } + selected := capability.ResolvedCognitiveBinding{ + Binding: capability.CognitiveBinding{ID: "builtin-local-binding-v1", Revision: 1}, + Provider: capability.ProviderInstance{ID: "builtin-local-lifeboat-v1", Revision: 1, Kind: registration.Kind}, + Model: capability.ModelDescriptor{ID: "builtin-local-model-v1", Revision: 1, ProviderInstanceID: "builtin-local-lifeboat-v1", ProviderModelID: "domovoy-lifeboat"}, + } + g.observe(ctx, selected, response, started) +} + func fallbackEligible(reason cognitive.FinishReason) bool { switch reason { case cognitive.FinishProviderUnavailable, cognitive.FinishModelUnavailable, cognitive.FinishTimeout, cognitive.FinishDegraded: diff --git a/cmd/cognitive-worker/gateway_registry_test.go b/cmd/cognitive-worker/gateway_registry_test.go index daa7c8e..e351ba4 100644 --- a/cmd/cognitive-worker/gateway_registry_test.go +++ b/cmd/cognitive-worker/gateway_registry_test.go @@ -188,6 +188,37 @@ func TestNoActiveBindingInvokesBuiltInLocalProvider(t *testing.T) { if response.Code != http.StatusOK || !provider.called || !strings.Contains(response.Body.String(), "локальный ответ") { t.Fatalf("status=%d provider_called=%t body=%s", response.Code, provider.called, response.Body.String()) } + if registry.observation == nil || registry.observation.BindingID != "builtin-local-binding-v1" || registry.observation.ProviderInstanceID != "builtin-local-lifeboat-v1" || registry.observation.ModelDescriptorID != "builtin-local-model-v1" || registry.observation.ResultClass != capability.CognitiveResultClass(cognitive.FinishCompleted) { + t.Fatalf("built-in usage observation=%+v", registry.observation) + } +} + +func TestBuiltInProviderFailureIsObservedWithoutBlockingFallback(t *testing.T) { + registry := ®istryStub{err: errors.New("no active binding")} + provider := &stubAdvisoryProvider{err: errors.New("local timeout")} + builtin := providerRegistration{Kind: "ollama-v1", Provider: provider} + gateway := cognitiveGateway{resolver: registry, builtinLocal: &builtin, now: time.Now} + request := httptest.NewRequest(http.MethodPost, "/v1/advisories", strings.NewReader(gatewayRequest())) + response := httptest.NewRecorder() + gateway.advisoryHandler(response, request) + if response.Code != http.StatusOK || registry.observation == nil { + t.Fatalf("status=%d observation=%+v body=%s", response.Code, registry.observation, response.Body.String()) + } + if registry.observation.ResultClass != capability.CognitiveResultClass(cognitive.FinishInvalidProviderResponse) || registry.observation.FailureType != capability.CognitiveFailureType(cognitive.FinishInvalidProviderResponse) { + t.Fatalf("failure observation=%+v", registry.observation) + } +} + +func TestBuiltInInterpretationIsObserved(t *testing.T) { + registry := ®istryStub{err: errors.New("no active binding")} + provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{FinishReason: cognitive.FinishCompleted, AdvisoryText: `{"primary_speech_act":"QUESTION","secondary_speech_act":"","confidence_millis":900,"applicability":"APPLICABLE","requested_outcome":"","needs_cognition":false,"clarification":"NOT_REQUIRED","selection_reason":"local","capability_hints":[]}`}} + builtin := providerRegistration{Kind: "openai-compatible-chat-v1", Provider: provider} + gateway := cognitiveGateway{resolver: registry, builtinLocal: &builtin, now: time.Now} + request := cognitive.GatewayInterpretationRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: "interpretation-1", ActorID: "actor-1", PrincipalID: "principal-1", ScopeID: "scope-1", Message: "что доступно", MaxOutputBytes: 4096, DeterministicSpeechAct: "QUESTION", DeterministicApplicability: cognitive.InterpretationApplicable} + result := gateway.interpretWithBuiltin(context.Background(), request, request.MaxOutputBytes) + if result.FinishReason != cognitive.FinishCompleted || registry.observation == nil || registry.observation.RequestID != "interpretation-1" { + t.Fatalf("result=%+v observation=%+v", result, registry.observation) + } } func TestProviderLossBetweenRoutingAndInvocationFallsBackOnce(t *testing.T) { diff --git a/cmd/cognitive-worker/interpretation.go b/cmd/cognitive-worker/interpretation.go index 1123d67..2135674 100644 --- a/cmd/cognitive-worker/interpretation.go +++ b/cmd/cognitive-worker/interpretation.go @@ -83,7 +83,18 @@ func (g cognitiveGateway) interpretWithBuiltin(ctx context.Context, request cogn return deterministicInterpretationResponse(request) } invocation := cognitive.AdvisoryRequest{Version: request.Version, RequestID: request.RequestID, ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: interpretationPrompt(request), Locale: request.Locale, MaxOutputBytes: maxOutputBytes, ProviderBindingID: "builtin-local-binding-v1", ProviderBindingRevision: 1, ProviderInstanceID: "builtin-local-lifeboat-v1", ModelDescriptorID: "builtin-local-model-v1", ProviderModelID: "domovoy-lifeboat"} + started := g.now() response, err := g.builtinLocal.Provider.Advise(ctx, invocation) + if response.RequestID == "" { + response.RequestID = request.RequestID + } + if response.Version == "" { + response.Version = request.Version + } + if err != nil { + response = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: "builtin-local-binding-v1", ProviderBindingRevision: 1, FinishReason: cognitive.FinishInvalidProviderResponse} + } + g.observeBuiltin(ctx, g.builtinLocal, response, started) if err != nil || response.FinishReason != cognitive.FinishCompleted { return deterministicInterpretationResponse(request) } diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 7d4294a..2a509a9 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000095-chat-build-metadata-rendering.json", - "task_id": "DEV-000095", + "task_file": "dev/tasks/DEV-000096-builtin-cognitive-usage-telemetry.json", + "task_id": "DEV-000096", "task_revision": 1 } diff --git a/dev/tasks/DEV-000096-builtin-cognitive-usage-telemetry.json b/dev/tasks/DEV-000096-builtin-cognitive-usage-telemetry.json new file mode 100644 index 0000000..48c69da --- /dev/null +++ b/dev/tasks/DEV-000096-builtin-cognitive-usage-telemetry.json @@ -0,0 +1,26 @@ +{ + "task_id": "DEV-000096", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "305a55a", + "risk_tier": "E", + "intent": "Record usage observations for the built-in Domovoy cognitive provider so routing and learning can account for local fallback success, latency, usage, and failure.", + "requirement_ids": ["Built-in cognitive calls are observable", "Telemetry failure does not block local chat"], + "allowed_change_scope": { + "modules": ["cognitive-worker", "development-governance"], + "paths": ["cmd/cognitive-worker/gateway.go", "cmd/cognitive-worker/interpretation.go", "cmd/cognitive-worker/gateway_registry_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000096-builtin-cognitive-usage-telemetry.json"] + }, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "deploy/**", "contracts/**", "cmd/cognitive-worker/interpretation_live_trace_test.go"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["existing CognitiveUsageObservation and bindingResolver.Observe"], "may_change": []}, + "semantic_owner": "capability registry owns cognitive usage observations; cognitive-worker is an observer", + "canonical_state": [], + "state_transitions": [], + "side_effects_and_privileges": ["Adds only an existing usage-observation side effect for built-in provider calls; no external mutation or credential access"], + "persistence_and_migrations": ["No schema or migration changes; uses existing observation endpoint and repository"], + "failure_retry_restart": ["Observation errors remain non-fatal; provider response and deterministic fallback behavior remain unchanged"], + "assumptions": ["The built-in provider identity remains the existing stable local lifeboat identity", "docker-test remains prohibited"], + "acceptance_scenarios": ["Successful built-in advisory call produces an observation", "Failed built-in advisory call produces a non-success observation", "Built-in interpretation call is observed", "Chat behavior remains available when observation persistence fails", "No secrets are included in observations"], + "required_tests": ["go test ./cmd/cognitive-worker", "go test ./...", "go vet ./...", "devctl preflight", "devctl check-diff", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +}