1- from typing import List , Tuple
1+ from datetime import date
2+ from typing import List , Optional , Tuple , Union
23
3- from sqlalchemy import select
4+ from sqlalchemy import ColumnElement , and_ , or_ , select
45from sqlalchemy .ext .asyncio import AsyncSession
56
67from app .models .paper import Paper as PaperModel
8+ from app .schemas .search_dto import (
9+ AdvancedSearchFilter ,
10+ ConditionGroup ,
11+ TextCondition ,
12+ )
13+
14+ # Mapping from DTO field names to SQLAlchemy model columns
15+ _FIELD_COLUMN = {
16+ "title" : PaperModel .title ,
17+ "abstract" : PaperModel .abstract ,
18+ }
719
820
921class SearchRepository :
1022 """Repository for search-related database operations."""
1123
1224 @staticmethod
1325 async def search_papers_by_embeddings (
14- db : AsyncSession , embeddings : List [List [float ]], limit : int = 5 , threshold : float = 0.4
26+ db : AsyncSession ,
27+ embeddings : List [List [float ]],
28+ limit : int = 5 ,
29+ threshold : float = 0.4 ,
30+ search_filter : Optional [AdvancedSearchFilter ] = None ,
1531 ) -> List [Tuple [PaperModel , float ]]:
1632 """
1733 Perform a vector search for papers based on a list of embeddings.
1834 Returns a list of (PaperModel, avg_distance) tuples ordered by ascending distance.
35+ Optionally applies advanced search filters (year range, text conditions).
1936 """
2037
2138 # Build distance expressions
@@ -30,6 +47,62 @@ async def search_papers_by_embeddings(
3047 .limit (limit )
3148 )
3249
50+ if search_filter :
51+ clauses = SearchRepository ._build_filter_clauses (search_filter )
52+ if clauses :
53+ stmt = stmt .where (and_ (* clauses ))
54+
3355 result = await db .execute (stmt )
3456 rows = result .fetchall ()
3557 return [(paper , float (dist )) for paper , dist in rows ]
58+
59+ @staticmethod
60+ def _build_filter_clauses (search_filter : AdvancedSearchFilter ) -> list :
61+ """Build a list of top-level SQLAlchemy filter clauses from the advanced filter."""
62+ clauses = []
63+
64+ if search_filter .year_from is not None :
65+ clauses .append (PaperModel .published_at >= date (search_filter .year_from , 1 , 1 ))
66+
67+ if search_filter .year_to is not None :
68+ clauses .append (PaperModel .published_at <= date (search_filter .year_to , 12 , 31 ))
69+
70+ condition_clause = SearchRepository ._build_group_clause (search_filter .root )
71+ if condition_clause is not None :
72+ clauses .append (condition_clause )
73+
74+ return clauses
75+
76+ @staticmethod
77+ def _build_group_clause (group : ConditionGroup ) -> Optional [ColumnElement [bool ]]:
78+ """Recursively build an AND/OR clause from a ConditionGroup."""
79+ if not group .children :
80+ return None
81+
82+ child_clauses = []
83+ for child in group .children :
84+ clause = SearchRepository ._build_node_clause (child )
85+ if clause is not None :
86+ child_clauses .append (clause )
87+
88+ if not child_clauses :
89+ return None
90+
91+ if group .operator == "AND" :
92+ return and_ (* child_clauses )
93+ return or_ (* child_clauses )
94+
95+ @staticmethod
96+ def _build_node_clause (
97+ node : Union [TextCondition , ConditionGroup ],
98+ ) -> Optional [ColumnElement [bool ]]:
99+ """Build a clause for a single node (condition or nested group)."""
100+ if node .type == "group" :
101+ return SearchRepository ._build_group_clause (node )
102+
103+ column = _FIELD_COLUMN [node .field ]
104+ pattern = f"%{ node .value } %"
105+
106+ if node .operator == "contains" :
107+ return column .ilike (pattern )
108+ return or_ (column .is_ (None ), ~ column .ilike (pattern ))
0 commit comments