package domain import ( "errors" "strings" "git.cin.su/m/Assistant/contracts/epistemics" ) var ( ErrInvalidResearchPlan = errors.New("invalid research plan") ErrInvalidResearchCandidate = errors.New("invalid research candidate") ErrNoResearchEvidence = errors.New("no research evidence") ) // EvaluateResearch turns connector evidence into an explainable, non-canonical // result. It is deliberately pure: it does not call a provider, persist a // claim, or promote an assessment into authority. func EvaluateResearch(plan epistemics.ResearchPlan, candidates []epistemics.ResearchCandidate, evaluatedAtUnixMillis int64) (epistemics.ResearchResult, error) { if err := plan.Validate(); err != nil || evaluatedAtUnixMillis < 1 { return epistemics.ResearchResult{}, ErrInvalidResearchPlan } result := epistemics.ResearchResult{PlanID: plan.ID, EvaluatedAtUnixMillis: evaluatedAtUnixMillis} if len(candidates) == 0 { result.Findings = []epistemics.ResearchFinding{{State: epistemics.FindingUnknown, Reason: "no connector evidence was returned"}} return result, nil } groups := make(map[string][]epistemics.ResearchCandidate) order := make([]string, 0, len(candidates)) for _, candidate := range candidates { if strings.TrimSpace(candidate.PropositionKey) == "" || strings.TrimSpace(candidate.Statement) == "" || candidate.Evidence == "" { return epistemics.ResearchResult{}, ErrInvalidResearchCandidate } if _, exists := groups[candidate.PropositionKey]; !exists { order = append(order, candidate.PropositionKey) } groups[candidate.PropositionKey] = append(groups[candidate.PropositionKey], candidate) } for _, key := range order { finding, err := evaluateGroup(key, groups[key], evaluatedAtUnixMillis) if err != nil { return epistemics.ResearchResult{}, err } result.Findings = append(result.Findings, finding) } return result, nil } func evaluateGroup(key string, candidates []epistemics.ResearchCandidate, evaluatedAt int64) (epistemics.ResearchFinding, error) { if len(candidates) == 0 { return epistemics.ResearchFinding{}, ErrNoResearchEvidence } first := candidates[0] firstBundle, err := compileResearchCandidate(first) if err != nil { return epistemics.ResearchFinding{}, ErrInvalidResearchCandidate } finding := epistemics.ResearchFinding{PropositionKey: key, Statement: first.Statement, Claim: firstBundle.Claim, Citations: []epistemics.Citation{firstBundle.Citation}, State: epistemics.FindingSupported} statements := map[string]struct{}{normalize(first.Statement): {}} if first.Snapshot.Freshness == epistemics.FreshnessUnknown || first.Evidence == epistemics.EvidenceAssumed || first.Coverage != epistemics.CoverageComplete { finding.State, finding.Reason = epistemics.FindingUnknown, "evidence is assumed, unknown or incomplete" } else if first.Snapshot.Freshness == epistemics.FreshnessStale { finding.State, finding.Reason = epistemics.FindingStale, "source snapshot is stale" } for _, candidate := range candidates[1:] { bundle, resolveErr := compileResearchCandidate(candidate) if resolveErr != nil { return epistemics.ResearchFinding{}, ErrInvalidResearchCandidate } finding.Citations = append(finding.Citations, bundle.Citation) statements[normalize(candidate.Statement)] = struct{}{} if candidate.Snapshot.Freshness == epistemics.FreshnessUnknown || candidate.Evidence == epistemics.EvidenceAssumed || candidate.Coverage != epistemics.CoverageComplete { finding.State, finding.Reason = epistemics.FindingUnknown, "at least one source has assumed, unknown or incomplete evidence" } else if candidate.Snapshot.Freshness == epistemics.FreshnessStale { finding.State, finding.Reason = epistemics.FindingStale, "at least one source snapshot is stale" } } if len(statements) > 1 { finding.State, finding.Reason = epistemics.FindingConflicted, "connector evidence contains incompatible statements" } for _, citation := range finding.Citations { if citation.Conflict == epistemics.ConflictPresent { finding.State, finding.Reason = epistemics.FindingConflicted, "connector marked evidence as conflicting" } } if len(finding.Citations) == 0 || evaluatedAt < 1 { return epistemics.ResearchFinding{}, ErrInvalidResearchCandidate } return finding, nil } func normalize(value string) string { return strings.ToLower(strings.Join(strings.Fields(value), " ")) } // candidateConflict keeps unknown source freshness explicit at the candidate // boundary. A connector may mark evidence as conflicting without changing the // source; group-level incompatible statements are detected separately. func compileResearchCandidate(candidate epistemics.ResearchCandidate) (epistemics.TruthBundle, error) { if candidate.Coverage == "" || candidate.Conflict == "" { return epistemics.TruthBundle{}, ErrInvalidResearchCandidate } citation, err := epistemics.ResolveCitation(candidate.Source, candidate.Snapshot, candidate.Excerpt, candidate.Evidence, candidate.Coverage, candidate.Conflict, candidate.Snapshot.CapturedAtUnixMillis) if err != nil { return epistemics.TruthBundle{}, err } observation := epistemics.Observation{ID: epistemics.ObservationID("observation:" + string(candidate.Excerpt.ID)), SourceSnapshot: candidate.Snapshot.ID, Excerpt: candidate.Excerpt.ID, Text: candidate.Excerpt.Text, ObservedAt: candidate.Snapshot.CapturedAtUnixMillis} claim := epistemics.Claim{ID: epistemics.ClaimID("claim:" + string(candidate.Excerpt.ID)), Statement: candidate.Statement, ObservationID: observation.ID, CitationID: citation.ID, Verification: epistemics.VerificationUnverified} return epistemics.TruthBundle{Source: candidate.Source, Snapshot: candidate.Snapshot, Excerpt: candidate.Excerpt, Observation: observation, Claim: claim, Belief: epistemics.Belief{ID: epistemics.BeliefID("belief:" + string(candidate.Excerpt.ID)), ClaimID: claim.ID, State: epistemics.BeliefProvisional}, Citation: citation}, nil }