diff --git a/activity/synthesis.go b/activity/synthesis.go index 2ea9551..63a8777 100644 --- a/activity/synthesis.go +++ b/activity/synthesis.go @@ -148,6 +148,23 @@ func (s *SynthesisActivities) ExtractFactsActivity(ctx context.Context, chunkID subject := strings.TrimSpace(match[1]) object := strings.TrimSpace(match[2]) + // Validation: skip empty or invalid extracts + if len(subject) == 0 || len(object) == 0 { + continue // Skip empty subject/object + } + + // Truncate overly long objects (avoid capturing entire sentence) + if len(object) > 500 { + logger.Info("truncating long object", "original_len", len(object), "subject", subject, "predicate", vp.predicate) + object = object[:500] + } + + // Truncate overly long subjects + if len(subject) > 200 { + logger.Info("truncating long subject", "original_len", len(subject), "predicate", vp.predicate) + subject = subject[:200] + } + // Boost confidence if subject/object are known entities confidence := 0.60 if entityNames[strings.ToLower(subject)] { @@ -226,6 +243,7 @@ func (s *SynthesisActivities) DetectContradictionsActivity(ctx context.Context, // PersistSynthesisActivity saves all synthesis results to the memory service. // Stage 5: Persists entities, facts, and queues contradictions for review. +// Returns error if any persistence fails (fail-safe semantics). func (s *SynthesisActivities) PersistSynthesisActivity(ctx context.Context, input PersistInput) error { logger := slog.Default() logger.Info("persisting synthesis results", @@ -235,6 +253,8 @@ func (s *SynthesisActivities) PersistSynthesisActivity(ctx context.Context, inpu "contradictions", len(input.Contradictions), ) + var errs []error + // Persist entities as knowledge records for _, entity := range input.Entities { _, err := s.memClient.Ingest(ctx, &memory.IngestRequest{ @@ -249,7 +269,8 @@ func (s *SynthesisActivities) PersistSynthesisActivity(ctx context.Context, inpu }, }) if err != nil { - logger.Warn("failed to persist entity", "entity", entity.Name, "error", err) + logger.Error("failed to persist entity", "entity", entity.Name, "error", err) + errs = append(errs, fmt.Errorf("persist entity %s: %w", entity.Name, err)) } } @@ -268,11 +289,18 @@ func (s *SynthesisActivities) PersistSynthesisActivity(ctx context.Context, inpu }, }) if err != nil { - logger.Warn("failed to persist fact", "subject", fact.Subject, "error", err) + logger.Error("failed to persist fact", "subject", fact.Subject, "predicate", fact.Predicate, "error", err) + errs = append(errs, fmt.Errorf("persist fact %s %s: %w", fact.Subject, fact.Predicate, err)) } } - logger.Info("synthesis persisted", "chunk_id", input.ChunkID) + // Return all accumulated errors (fail-safe semantics) + if len(errs) > 0 { + logger.Error("persistence failed with errors", "error_count", len(errs), "chunk_id", input.ChunkID) + return fmt.Errorf("persist synthesis: %d errors - %v", len(errs), errs) + } + + logger.Info("synthesis persisted successfully", "chunk_id", input.ChunkID) return nil } diff --git a/activity/synthesis_test.go b/activity/synthesis_test.go index 938479c..b8a0fea 100644 --- a/activity/synthesis_test.go +++ b/activity/synthesis_test.go @@ -2,6 +2,7 @@ package activity import ( "context" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -120,6 +121,58 @@ func TestClassifyEntity(t *testing.T) { assert.Equal(t, "concept", classifyEntity("SomeRandomThing")) } +// --- Tests for ExtractFactsActivity Validation --- + +func TestExtractFacts_WithValidation(t *testing.T) { + sa := NewSynthesisActivities(nil) + // Test with very long object that should be truncated + longText := "Kubernetes uses " + strings.Repeat("very long object name that should be truncated ", 20) + entities := []ExtractedEntity{} + + facts, err := sa.ExtractFactsActivity(testCtx, "chunk-1", longText, entities) + assert.NoError(t, err) + + // Verify no fact has object > 500 chars + for _, f := range facts { + assert.LessOrEqual(t, len(f.Object), 500, "object should be truncated to 500 chars") + } +} + +func TestExtractFacts_SkipsEmpty(t *testing.T) { + sa := NewSynthesisActivities(nil) + // Text with empty patterns that would extract nothing + text := "Something uses and other things" + entities := []ExtractedEntity{} + + facts, err := sa.ExtractFactsActivity(testCtx, "chunk-1", text, entities) + assert.NoError(t, err) + + // Verify no empty facts + for _, f := range facts { + assert.NotEmpty(t, f.Subject, "subject should not be empty") + assert.NotEmpty(t, f.Object, "object should not be empty") + } +} + +func TestPersistSynthesis_EmptyInput(t *testing.T) { + // Test that empty input is handled (no entities or facts to persist) + // Note: This test requires a mock memory service; for now we just test structure + input := PersistInput{ + ChunkID: "chunk-123", + Project: "test", + Source: "test://1", + Kind: "L1", + Entities: []ExtractedEntity{}, // Empty + Facts: []ExtractedFact{}, // Empty + Contradictions: []ContradictionResult{}, + } + + // Verify input structure is valid + assert.Equal(t, "chunk-123", input.ChunkID) + assert.Equal(t, 0, len(input.Entities)) + assert.Equal(t, 0, len(input.Facts)) +} + // helper func entityNames(entities []ExtractedEntity) []string { names := make([]string, len(entities))