@@ -755,3 +755,182 @@ async def test_search_title_via_repository_direct(search_service, session_maker,
755755 # Should find the entity without throwing FTS5 syntax errors
756756 assert len (results ) >= 1
757757 assert any (result .title == "Note (with parentheses)" for result in results )
758+
759+
760+ # Tests for duplicate observation permalink deduplication
761+
762+
763+ @pytest .mark .asyncio
764+ async def test_index_entity_with_duplicate_observations (
765+ search_service , session_maker , test_project
766+ ):
767+ """Test that indexing an entity with duplicate observations doesn't cause unique constraint violations.
768+
769+ Two observations with the same category and content generate identical permalinks,
770+ which would violate the unique constraint on the search_index table.
771+ """
772+ from basic_memory .repository import EntityRepository , ObservationRepository
773+ from unittest .mock import AsyncMock
774+ from datetime import datetime
775+
776+ entity_repo = EntityRepository (session_maker , project_id = test_project .id )
777+ obs_repo = ObservationRepository (session_maker , project_id = test_project .id )
778+
779+ # Create entity
780+ entity_data = {
781+ "title" : "Entity With Duplicate Observations" ,
782+ "entity_type" : "note" ,
783+ "entity_metadata" : {},
784+ "content_type" : "text/markdown" ,
785+ "file_path" : "test/duplicate-obs.md" ,
786+ "permalink" : "test/duplicate-obs" ,
787+ "project_id" : test_project .id ,
788+ "created_at" : datetime .now (),
789+ "updated_at" : datetime .now (),
790+ }
791+
792+ entity = await entity_repo .create (entity_data )
793+
794+ # Create duplicate observations - same category and content
795+ duplicate_content = "This is a duplicated observation"
796+ await obs_repo .create (
797+ {"entity_id" : entity .id , "category" : "note" , "content" : duplicate_content }
798+ )
799+ await obs_repo .create (
800+ {"entity_id" : entity .id , "category" : "note" , "content" : duplicate_content }
801+ )
802+
803+ # Reload entity with observations (get_by_permalink eagerly loads observations)
804+ entity = await entity_repo .get_by_permalink ("test/duplicate-obs" )
805+
806+ # Verify we have duplicate observations
807+ assert len (entity .observations ) == 2
808+ assert entity .observations [0 ].permalink == entity .observations [1 ].permalink
809+
810+ # Mock file service to avoid file I/O
811+ search_service .file_service .read_entity_content = AsyncMock (return_value = "" )
812+
813+ # This should not raise a unique constraint violation
814+ await search_service .index_entity (entity )
815+
816+ # Verify entity is searchable
817+ results = await search_service .search (SearchQuery (text = "Duplicate Observations" ))
818+ assert len (results ) >= 1
819+ assert any (r .title == "Entity With Duplicate Observations" for r in results )
820+
821+
822+ @pytest .mark .asyncio
823+ async def test_index_entity_dedupes_observations_by_permalink (
824+ search_service , session_maker , test_project
825+ ):
826+ """Test that only unique observation permalinks are indexed.
827+
828+ When an entity has observations with identical permalinks, only the first one
829+ should be indexed to avoid unique constraint violations.
830+ """
831+ from basic_memory .repository import EntityRepository , ObservationRepository
832+ from unittest .mock import AsyncMock
833+ from datetime import datetime
834+
835+ entity_repo = EntityRepository (session_maker , project_id = test_project .id )
836+ obs_repo = ObservationRepository (session_maker , project_id = test_project .id )
837+
838+ # Create entity
839+ entity_data = {
840+ "title" : "Dedupe Test Entity" ,
841+ "entity_type" : "note" ,
842+ "entity_metadata" : {},
843+ "content_type" : "text/markdown" ,
844+ "file_path" : "test/dedupe-test.md" ,
845+ "permalink" : "test/dedupe-test" ,
846+ "project_id" : test_project .id ,
847+ "created_at" : datetime .now (),
848+ "updated_at" : datetime .now (),
849+ }
850+
851+ entity = await entity_repo .create (entity_data )
852+
853+ # Create three observations: two duplicates and one unique
854+ duplicate_content = "Duplicate observation content"
855+ unique_content = "Unique observation content"
856+
857+ await obs_repo .create (
858+ {"entity_id" : entity .id , "category" : "note" , "content" : duplicate_content }
859+ )
860+ await obs_repo .create (
861+ {"entity_id" : entity .id , "category" : "note" , "content" : duplicate_content }
862+ )
863+ await obs_repo .create ({"entity_id" : entity .id , "category" : "note" , "content" : unique_content })
864+
865+ # Reload entity with observations (get_by_permalink eagerly loads observations)
866+ entity = await entity_repo .get_by_permalink ("test/dedupe-test" )
867+ assert len (entity .observations ) == 3
868+
869+ # Mock file service to avoid file I/O
870+ search_service .file_service .read_entity_content = AsyncMock (return_value = "" )
871+
872+ # Index the entity
873+ await search_service .index_entity (entity )
874+
875+ # Search for the unique observation - should find it
876+ results = await search_service .search (SearchQuery (text = "Unique observation" ))
877+ assert len (results ) >= 1
878+
879+ # Search for duplicate observation - should find it (only one indexed)
880+ results = await search_service .search (SearchQuery (text = "Duplicate observation" ))
881+ assert len (results ) >= 1
882+
883+
884+ @pytest .mark .asyncio
885+ async def test_index_entity_multiple_categories_same_content (
886+ search_service , session_maker , test_project
887+ ):
888+ """Test that observations with same content but different categories are not deduped.
889+
890+ The permalink includes the category, so observations with different categories
891+ but same content should have different permalinks and both be indexed.
892+ """
893+ from basic_memory .repository import EntityRepository , ObservationRepository
894+ from unittest .mock import AsyncMock
895+ from datetime import datetime
896+
897+ entity_repo = EntityRepository (session_maker , project_id = test_project .id )
898+ obs_repo = ObservationRepository (session_maker , project_id = test_project .id )
899+
900+ # Create entity
901+ entity_data = {
902+ "title" : "Multi Category Entity" ,
903+ "entity_type" : "note" ,
904+ "entity_metadata" : {},
905+ "content_type" : "text/markdown" ,
906+ "file_path" : "test/multi-category.md" ,
907+ "permalink" : "test/multi-category" ,
908+ "project_id" : test_project .id ,
909+ "created_at" : datetime .now (),
910+ "updated_at" : datetime .now (),
911+ }
912+
913+ entity = await entity_repo .create (entity_data )
914+
915+ # Create observations with same content but different categories
916+ shared_content = "Shared content across categories"
917+ await obs_repo .create ({"entity_id" : entity .id , "category" : "tech" , "content" : shared_content })
918+ await obs_repo .create ({"entity_id" : entity .id , "category" : "design" , "content" : shared_content })
919+
920+ # Reload entity with observations (get_by_permalink eagerly loads observations)
921+ entity = await entity_repo .get_by_permalink ("test/multi-category" )
922+ assert len (entity .observations ) == 2
923+
924+ # Verify permalinks are different due to different categories
925+ permalinks = {obs .permalink for obs in entity .observations }
926+ assert len (permalinks ) == 2 # Should be 2 unique permalinks
927+
928+ # Mock file service to avoid file I/O
929+ search_service .file_service .read_entity_content = AsyncMock (return_value = "" )
930+
931+ # Index the entity - both should be indexed since permalinks differ
932+ await search_service .index_entity (entity )
933+
934+ # Search for the shared content - should find both observations
935+ results = await search_service .search (SearchQuery (text = "Shared content" ))
936+ assert len (results ) >= 2
0 commit comments