Coverage for src/local_deep_research/database/models/news.py: 99%

117 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

1""" 

2Database models for news subscriptions and related functionality. 

3These tables are created in per-user encrypted databases. 

4""" 

5 

6from sqlalchemy import ( 

7 Column, 

8 Integer, 

9 String, 

10 JSON, 

11 Text, 

12 Boolean, 

13 ForeignKey, 

14 Enum, 

15 and_, 

16) 

17from sqlalchemy_utc import UtcDateTime, utcnow 

18import enum 

19 

20from .base import Base 

21 

22 

23class CardType(enum.Enum): 

24 """Types of cards in the system""" 

25 

26 NEWS = "news" 

27 RESEARCH = "research" 

28 UPDATE = "update" 

29 OVERVIEW = "overview" 

30 

31 

32class RatingType(enum.Enum): 

33 """Types of ratings""" 

34 

35 RELEVANCE = "relevance" # Thumbs up/down 

36 QUALITY = "quality" # 1-5 stars 

37 

38 

39class SubscriptionType(enum.Enum): 

40 """Types of subscriptions""" 

41 

42 SEARCH = "search" 

43 TOPIC = "topic" 

44 

45 

46class SubscriptionStatus(enum.Enum): 

47 """Status of subscriptions""" 

48 

49 ACTIVE = "active" 

50 PAUSED = "paused" 

51 EXPIRED = "expired" 

52 ERROR = "error" 

53 

54 

55class NewsSubscription(Base): 

56 """User's news subscriptions""" 

57 

58 __tablename__ = "news_subscriptions" 

59 

60 id = Column(String(50), primary_key=True) 

61 

62 # Subscription details 

63 name = Column(String(255)) # Optional friendly name 

64 subscription_type = Column( 

65 String(20), nullable=False 

66 ) # 'search' or 'topic' 

67 query_or_topic = Column(Text, nullable=False) 

68 refresh_interval_minutes = Column( 

69 Integer, default=1440 

70 ) # Default 24 hours = 1440 minutes 

71 frequency = Column( 

72 String(50), default="daily" 

73 ) # daily, weekly, hourly, etc. 

74 

75 # Timing 

76 created_at = Column(UtcDateTime, default=utcnow()) 

77 updated_at = Column( 

78 UtcDateTime, 

79 default=utcnow(), 

80 onupdate=utcnow(), 

81 ) 

82 last_refresh = Column(UtcDateTime) 

83 next_refresh = Column(UtcDateTime) 

84 expires_at = Column(UtcDateTime) # Optional expiration 

85 

86 # Source tracking 

87 source_type = Column(String(50)) # 'manual', 'research', 'news_topic' 

88 source_id = Column(String(100)) # ID of source (research_id, news_id) 

89 created_from = Column(Text) # Description of source 

90 

91 # Organization 

92 folder = Column(String(100)) # Folder name 

93 folder_id = Column(String(36)) # Folder ID 

94 notes = Column(Text) # User notes 

95 

96 # Model configuration 

97 model_provider = Column(String(50)) # OLLAMA, OPENAI, ANTHROPIC, etc. 

98 model = Column(String(100)) # Specific model name 

99 search_strategy = Column(String(50)) # Strategy for searches 

100 custom_endpoint = Column(String(255)) # Custom API endpoint if used 

101 

102 # Search configuration 

103 search_engine = Column(String(50)) # Search engine to use 

104 search_iterations = Column( 

105 Integer, default=3 

106 ) # Number of search iterations 

107 questions_per_iteration = Column( 

108 Integer, default=5 

109 ) # Questions per iteration 

110 

111 # State 

112 # `status` is the single source of truth for whether a subscription is 

113 # switched on. create_subscription() and update_subscription() write only 

114 # status; scheduling decisions (active_filter / due_filter below) key off 

115 # it exclusively. 

116 status = Column(String(20), default="active") 

117 # Legacy mirror of `status == "active"`. It is NOT kept in sync on the 

118 # create/update paths (create_subscription leaves it at the default True 

119 # even for paused subscriptions), so it must never be used to decide 

120 # whether to run a subscription — use status / the filters below instead. 

121 # Retained only for backwards-compatible serialization; not dropped because 

122 # this model lives in per-user encrypted databases where a column migration 

123 # is costly and risky. 

124 is_active = Column(Boolean, default=True) # Legacy mirror; see above 

125 error_count = Column(Integer, default=0) 

126 last_error = Column(Text) 

127 

128 # Additional data 

129 extra_data = Column(JSON) # Additional flexible data 

130 

131 @classmethod 

132 def active_filter(cls): 

133 """SQLAlchemy predicate for subscriptions that are switched on. 

134 

135 Single authoritative definition of "active", keyed on the `status` 

136 column (see the column comments above). Use this instead of 

137 re-spelling ``status == "active"`` or filtering on ``is_active``. 

138 """ 

139 return cls.status == SubscriptionStatus.ACTIVE.value 

140 

141 @classmethod 

142 def due_filter(cls, now): 

143 """SQLAlchemy predicate for active subscriptions whose run is due. 

144 

145 ``now`` should be a timezone-aware UTC datetime. The ``next_refresh 

146 is not None`` guard matters: a freshly created subscription always has 

147 next_refresh set, but defending against NULL keeps a stray NULL row 

148 from ever being treated as "infinitely overdue". 

149 """ 

150 return and_( 

151 cls.active_filter(), 

152 cls.next_refresh.is_not(None), 

153 cls.next_refresh <= now, 

154 ) 

155 

156 

157class SubscriptionFolder(Base): 

158 """Folders for organizing subscriptions""" 

159 

160 __tablename__ = "subscription_folders" 

161 

162 id = Column(String(36), primary_key=True) # UUID 

163 name = Column(String(100), nullable=False) 

164 description = Column(Text) 

165 color = Column(String(7)) # Hex color 

166 icon = Column(String(50)) # Icon identifier 

167 

168 # Timestamps 

169 created_at = Column(UtcDateTime, default=utcnow()) 

170 updated_at = Column( 

171 UtcDateTime, 

172 default=utcnow(), 

173 onupdate=utcnow(), 

174 ) 

175 

176 # Settings 

177 is_default = Column(Boolean, default=False) 

178 sort_order = Column(Integer, default=0) 

179 

180 def to_dict(self): 

181 """Convert folder to dictionary.""" 

182 return { 

183 "id": self.id, 

184 "name": self.name, 

185 "description": self.description, 

186 "color": self.color, 

187 "icon": self.icon, 

188 "created_at": self.created_at.isoformat() 

189 if self.created_at 

190 else None, 

191 "updated_at": self.updated_at.isoformat() 

192 if self.updated_at 

193 else None, 

194 "is_default": self.is_default, 

195 "sort_order": self.sort_order, 

196 } 

197 

198 

199class NewsCard(Base): 

200 """Individual news cards/items""" 

201 

202 __tablename__ = "news_cards" 

203 

204 id = Column(String(50), primary_key=True) 

205 

206 # Content 

207 title = Column(String(500), nullable=False) 

208 summary = Column(Text) 

209 content = Column(Text) 

210 url = Column(String(1000)) 

211 

212 # Source info 

213 source_name = Column(String(200)) 

214 source_type = Column(String(50)) # 'research', 'rss', 'api', etc. 

215 source_id = Column(String(100)) # ID in source system 

216 

217 # Categorization 

218 category = Column(String(100)) 

219 tags = Column(JSON) # List of tags 

220 card_type = Column(Enum(CardType), default=CardType.NEWS) 

221 

222 # Timing 

223 published_at = Column(UtcDateTime) 

224 discovered_at = Column(UtcDateTime, default=utcnow()) 

225 

226 # Interaction tracking 

227 is_read = Column(Boolean, default=False) 

228 read_at = Column(UtcDateTime) 

229 is_saved = Column(Boolean, default=False) 

230 saved_at = Column(UtcDateTime) 

231 

232 # Metadata 

233 extra_data = Column(JSON) # Flexible additional data 

234 

235 # Subscription link 

236 subscription_id = Column(String(50), ForeignKey("news_subscriptions.id")) 

237 

238 

239class UserRating(Base): 

240 """User ratings/feedback on news items""" 

241 

242 __tablename__ = "news_user_ratings" 

243 

244 id = Column(Integer, primary_key=True) 

245 

246 # What was rated 

247 card_id = Column(String(50), ForeignKey("news_cards.id"), nullable=False) 

248 rating_type = Column(Enum(RatingType), nullable=False) 

249 

250 # Rating value 

251 rating_value = Column(String(20)) # 'up', 'down', or numeric 

252 

253 # When 

254 created_at = Column(UtcDateTime, default=utcnow()) 

255 

256 # Optional feedback 

257 comment = Column(Text) 

258 tags = Column(JSON) # User-applied tags 

259 

260 

261class UserPreference(Base): 

262 """User preferences for news""" 

263 

264 __tablename__ = "news_user_preferences" 

265 

266 id = Column(Integer, primary_key=True) 

267 

268 # Preference key-value pairs 

269 key = Column(String(100), nullable=False, unique=True) 

270 value = Column(JSON) 

271 

272 # Metadata 

273 created_at = Column(UtcDateTime, default=utcnow()) 

274 updated_at = Column( 

275 UtcDateTime, 

276 default=utcnow(), 

277 onupdate=utcnow(), 

278 ) 

279 

280 

281class NewsInterest(Base): 

282 """User's declared interests for news""" 

283 

284 __tablename__ = "news_interests" 

285 

286 id = Column(Integer, primary_key=True) 

287 

288 # Interest details 

289 topic = Column(String(200), nullable=False) 

290 interest_type = Column(String(50)) # 'positive', 'negative', 'keyword' 

291 strength = Column(Integer, default=5) # 1-10 scale 

292 

293 # Timing 

294 created_at = Column(UtcDateTime, default=utcnow()) 

295 expires_at = Column(UtcDateTime) # Optional expiration 

296 

297 # Source 

298 source = Column(String(50)) # 'manual', 'inferred', 'imported' 

299 source_id = Column(String(100))