140 lines
7.1 KiB
Go
140 lines
7.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.cin.su/m/Assistant/contracts/capability"
|
|
"git.cin.su/m/Assistant/contracts/cognitive"
|
|
)
|
|
|
|
func (g cognitiveGateway) taskEvaluationHandler(w http.ResponseWriter, r *http.Request) {
|
|
var request capability.TaskEvaluationExecutionRequest
|
|
if !decodeJSON(w, r, 256<<10, &request) {
|
|
return
|
|
}
|
|
if err := request.Profile.Validate(); err != nil || request.CapabilityID == "" || request.CapabilityID != request.Profile.CapabilityID || request.BindingID == "" || request.BindingRevision < 1 || request.ActorID == "" || request.PrincipalID == "" || request.ScopeID == "" || request.MaxOutputBytes < 1 || len(request.Cases) != len(request.Profile.RequiredCaseIDs) {
|
|
http.Error(w, "invalid task evaluation execution request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
resolver, ok := g.resolver.(candidateBindingResolver)
|
|
if !ok {
|
|
http.Error(w, "exact candidate resolver unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
selected, err := resolver.ResolveCandidate(r.Context(), request.BindingID, request.BindingRevision)
|
|
if err != nil || selected.Binding.ID != request.BindingID || selected.Binding.Revision != request.BindingRevision || selected.Binding.CapabilityID != request.CapabilityID || selected.Binding.OwnerPrincipalID != request.PrincipalID {
|
|
http.Error(w, "cognitive binding snapshot unavailable", http.StatusConflict)
|
|
return
|
|
}
|
|
if selected.Binding.DataPolicy != capability.DataPolicyLocalOnly && !request.EgressAcknowledged {
|
|
http.Error(w, "external egress acknowledgement required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
caseSet := make(map[string]struct{}, len(request.Profile.RequiredCaseIDs))
|
|
seenCases := make(map[string]struct{}, len(request.Cases))
|
|
for _, id := range request.Profile.RequiredCaseIDs {
|
|
caseSet[id] = struct{}{}
|
|
}
|
|
for _, item := range request.Cases {
|
|
if item.CaseID == "" || item.Prompt == "" || item.EvidenceArtifactRef == "" {
|
|
http.Error(w, "invalid task evaluation case", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if _, exists := caseSet[item.CaseID]; !exists {
|
|
http.Error(w, "task evaluation case is not in profile", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if _, exists := seenCases[item.CaseID]; exists {
|
|
http.Error(w, "duplicate task evaluation case", http.StatusBadRequest)
|
|
return
|
|
}
|
|
seenCases[item.CaseID] = struct{}{}
|
|
}
|
|
if len(seenCases) != len(caseSet) {
|
|
http.Error(w, "task evaluation case set is incomplete", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// One bounded fan-out wave uses one binding snapshot. If that candidate is
|
|
// unavailable, only this binding is excluded and the existing resolver
|
|
// chooses the next eligible candidate. Provider output remains untrusted
|
|
// until the independent verifier accepts it downstream.
|
|
requirement := capability.CognitiveRequirement{CapabilityID: request.CapabilityID, OwnerPrincipalID: request.PrincipalID, DataPolicy: selected.Binding.DataPolicy, RequiredModalities: []capability.ModelModality{capability.ModalityText}, RequiredCapabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, AllowCanary: g.allowCanary, MaxOutputBytes: request.MaxOutputBytes}
|
|
var observations []capability.TaskCaseObservation
|
|
var responses []capability.TaskCaseResponse
|
|
for attempt := 0; attempt < 3; attempt++ {
|
|
provider := g.provider(selected)
|
|
if provider == nil {
|
|
observations, responses = taskEvaluationUnavailable(request.Cases)
|
|
} else {
|
|
observations, responses = g.executeTaskEvaluationCases(r.Context(), request, selected, provider)
|
|
}
|
|
if taskEvaluationResponsesUsable(responses) {
|
|
break
|
|
}
|
|
requirement.ExcludedBindingIDs = append(requirement.ExcludedBindingIDs, selected.Binding.ID)
|
|
fallback, resolveErr := g.resolver.Resolve(r.Context(), requirement)
|
|
fallbackProvider := g.provider(fallback)
|
|
if resolveErr != nil || fallbackProvider == nil || fallback.Binding.ID == selected.Binding.ID || (fallback.Binding.DataPolicy != capability.DataPolicyLocalOnly && !request.EgressAcknowledged) {
|
|
break
|
|
}
|
|
selected = fallback
|
|
}
|
|
|
|
writeTaskEvaluationJSON(w, capability.TaskEvaluationExecutionResponse{ProfileID: request.Profile.ID, BindingID: selected.Binding.ID, BindingRevision: selected.Binding.Revision, Observations: observations, Responses: responses, ObservedAtUnixMillis: time.Now().UnixMilli()})
|
|
}
|
|
|
|
func (g cognitiveGateway) executeTaskEvaluationCases(ctx context.Context, request capability.TaskEvaluationExecutionRequest, selected capability.ResolvedCognitiveBinding, provider advisoryProvider) ([]capability.TaskCaseObservation, []capability.TaskCaseResponse) {
|
|
observations := make([]capability.TaskCaseObservation, len(request.Cases))
|
|
responses := make([]capability.TaskCaseResponse, len(request.Cases))
|
|
var wg sync.WaitGroup
|
|
for i, item := range request.Cases {
|
|
wg.Add(1)
|
|
go func(index int, item capability.TaskEvaluationCase) {
|
|
defer wg.Done()
|
|
started := time.Now()
|
|
invocation := cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(fmt.Sprintf("task-evaluation:%s:%s", request.Profile.ID, item.CaseID)), ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: item.Prompt, Locale: "en-US", MaxOutputBytes: int(request.MaxOutputBytes), ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, ProviderInstanceID: selected.Provider.ID, ModelDescriptorID: selected.Model.ID, ProviderModelID: selected.Model.ProviderModelID}
|
|
response, invokeErr := g.invokeAdmitted(ctx, string(invocation.RequestID), provider, invocation, int(request.MaxOutputBytes))
|
|
latency := time.Since(started).Microseconds()
|
|
observations[index] = capability.TaskCaseObservation{CaseID: item.CaseID, Result: capability.EvaluationUnknown, CriticalFailure: invokeErr != nil || response.FinishReason != cognitive.FinishCompleted || strings.TrimSpace(response.AdvisoryText) == "", EvidenceArtifactRef: item.EvidenceArtifactRef, LatencyMicros: latency}
|
|
responses[index] = capability.TaskCaseResponse{CaseID: item.CaseID, ResponseText: response.AdvisoryText, LatencyMicros: latency, EvidenceArtifactRef: item.EvidenceArtifactRef}
|
|
}(i, item)
|
|
}
|
|
wg.Wait()
|
|
return observations, responses
|
|
}
|
|
|
|
func taskEvaluationUnavailable(cases []capability.TaskEvaluationCase) ([]capability.TaskCaseObservation, []capability.TaskCaseResponse) {
|
|
observations := make([]capability.TaskCaseObservation, len(cases))
|
|
responses := make([]capability.TaskCaseResponse, len(cases))
|
|
for i, item := range cases {
|
|
observations[i] = capability.TaskCaseObservation{CaseID: item.CaseID, Result: capability.EvaluationUnknown, CriticalFailure: true, EvidenceArtifactRef: item.EvidenceArtifactRef}
|
|
responses[i] = capability.TaskCaseResponse{CaseID: item.CaseID, EvidenceArtifactRef: item.EvidenceArtifactRef}
|
|
}
|
|
return observations, responses
|
|
}
|
|
|
|
func taskEvaluationResponsesUsable(responses []capability.TaskCaseResponse) bool {
|
|
if len(responses) == 0 {
|
|
return false
|
|
}
|
|
for _, response := range responses {
|
|
if strings.TrimSpace(response.ResponseText) == "" {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func writeTaskEvaluationJSON(w http.ResponseWriter, value any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(value)
|
|
}
|