Coverage for src/local_deep_research/database/models/note.py: 94%

102 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-19 23:35 +0000

1""" 

2Note-specific models for AI-connected notes feature. 

3 

4Notes are stored as Documents with source_type='note'. This module contains 

5auxiliary tables for note-specific features: 

6- Version history 

7- Wiki-style linking 

8- Research integration 

9- Synthesis tracking 

10 

11Naming note: the `note_*` table names are a service-layer scoping 

12convention — every FK actually points at `documents.id`, not at a dedicated 

13notes table. Renaming to `document_*` was considered during review since the 

14shapes (versions, links, research-pins) could apply to any Document, but 

15`change_type` enum values like `auto_save` / `pre_restore` are note-specific 

16semantics and would be meaningless for research-downloaded PDFs. Revisit the 

17naming only when there's a real second use case for versioning/linking on 

18other Document subtypes. 

19""" 

20 

21import enum 

22 

23from sqlalchemy import ( 

24 JSON, 

25 Boolean, 

26 CheckConstraint, 

27 Column, 

28 Enum, 

29 ForeignKey, 

30 Index, 

31 Integer, 

32 String, 

33 Text, 

34 UniqueConstraint, 

35 text, 

36) 

37from sqlalchemy.orm import backref, relationship 

38from sqlalchemy_utc import UtcDateTime, utcnow 

39 

40from .base import Base 

41 

42 

43class NoteChangeType(str, enum.Enum): 

44 """Valid values for NoteVersion.change_type.""" 

45 

46 INITIAL = "initial" 

47 AUTO_SAVE = "auto_save" 

48 MANUAL_SAVE = "manual_save" 

49 PRE_RESTORE = "pre_restore" 

50 RESTORE = "restore" 

51 

52 

53class NoteSynthesisType(str, enum.Enum): 

54 """Valid values for NoteSynthesis.synthesis_type.""" 

55 

56 MERGE = "merge" 

57 SUMMARIZE = "summarize" 

58 COMPARE = "compare" 

59 

60 

61class NoteVersion(Base): 

62 """ 

63 Stores version history for notes (documents with source_type='note'). 

64 Enables undo, restore, and semantic change tracking. 

65 """ 

66 

67 __tablename__ = "note_versions" 

68 

69 # UUID PK (String(36)) rather than autoincrement Integer — versions may 

70 # be URL-shared / bookmarked, so IDs are externally visible. The other 

71 # note junction tables use Integer autoincrement because they're 

72 # internal-only; the asymmetry is intentional. 

73 id = Column(String(36), primary_key=True) 

74 

75 # The document (note) this version belongs to 

76 # Indexed via the composite idx_note_version_created below. 

77 document_id = Column( 

78 String(36), 

79 ForeignKey("documents.id", ondelete="CASCADE"), 

80 nullable=False, 

81 ) 

82 

83 # Snapshot of the note at this version 

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

85 content = Column(Text, nullable=False) 

86 tags = Column( 

87 JSON, nullable=False, default=list, server_default=text("'[]'") 

88 ) 

89 

90 # Change metadata (see NoteChangeType) 

91 change_type = Column( 

92 Enum( 

93 NoteChangeType, 

94 values_callable=lambda obj: [e.value for e in obj], 

95 name="note_change_type", 

96 ), 

97 nullable=False, 

98 ) 

99 change_summary = Column( 

100 Text, nullable=True 

101 ) # LLM-generated description of changes 

102 content_hash = Column( 

103 String(64), nullable=False 

104 ) # SHA-256 for deduplication 

105 

106 # Timestamps 

107 created_at = Column( 

108 UtcDateTime, 

109 default=utcnow(), 

110 server_default=utcnow(), 

111 nullable=False, 

112 ) 

113 

114 # Relationships 

115 document = relationship( 

116 "Document", 

117 backref=backref("note_versions", passive_deletes=True), 

118 ) 

119 

120 __table_args__ = ( 

121 Index("idx_note_version_created", "document_id", "created_at"), 

122 UniqueConstraint( 

123 "document_id", "content_hash", name="uix_note_version_content" 

124 ), 

125 # Mirror NoteChangeType members above. The ORM Enum() with 

126 # values_callable suppresses SQLAlchemy's auto-CHECK, so declare 

127 # it explicitly here — parity with the migration. 

128 CheckConstraint( 

129 "change_type IN ('initial', 'auto_save', 'manual_save', " 

130 "'pre_restore', 'restore')", 

131 name="ck_note_change_type", 

132 ), 

133 ) 

134 

135 def __repr__(self): 

136 doc_id = self.document_id[:8] if self.document_id else "None" 

137 return f"<NoteVersion(document_id={doc_id}, id={self.id[:8] if self.id else 'None'})>" 

138 

139 

140class NoteLink(Base): 

141 """ 

142 Tracks wiki-style [[links]] between notes (documents with source_type='note'). 

143 Enables backlinks feature and knowledge graph visualization. 

144 """ 

145 

146 __tablename__ = "note_links" 

147 

148 id = Column(Integer, primary_key=True, autoincrement=True) 

149 

150 # Source document (the note containing the link). 

151 # Not individually indexed — the UNIQUE(source, target) constraint 

152 # below produces a usable index for single-column lookups on source. 

153 source_document_id = Column( 

154 String(36), 

155 ForeignKey("documents.id", ondelete="CASCADE"), 

156 nullable=False, 

157 ) 

158 

159 # Target document (the note being linked to). 

160 # NOT individually indexed here — the explicit `idx_note_link_target` 

161 # below in __table_args__ is the source of truth for this index. 

162 # Pre-fix the column also carried `index=True`, which made 

163 # SQLAlchemy create a second (auto-named) index over the same 

164 # column. Migration 0021 already only creates `idx_note_link_target`, 

165 # so production DBs are fine; this aligns the ORM declaration with 

166 # what actually lives on disk. 

167 target_document_id = Column( 

168 String(36), 

169 ForeignKey("documents.id", ondelete="CASCADE"), 

170 nullable=False, 

171 ) 

172 

173 # The text used in the link (e.g., [[My Note Title]]) 

174 link_text = Column(String(500), nullable=False) 

175 

176 # Whether this link was auto-suggested by AI 

177 auto_suggested = Column( 

178 Boolean, default=False, server_default=text("0"), nullable=False 

179 ) 

180 

181 # Timestamps 

182 created_at = Column( 

183 UtcDateTime, 

184 default=utcnow(), 

185 server_default=utcnow(), 

186 nullable=False, 

187 ) 

188 

189 # Relationships 

190 source_document = relationship( 

191 "Document", 

192 foreign_keys=[source_document_id], 

193 backref=backref("outgoing_note_links", passive_deletes=True), 

194 ) 

195 target_document = relationship( 

196 "Document", 

197 foreign_keys=[target_document_id], 

198 backref=backref("incoming_note_links", passive_deletes=True), 

199 ) 

200 

201 # Ensure unique links (one link per source-target pair) and prevent self-links 

202 __table_args__ = ( 

203 UniqueConstraint( 

204 "source_document_id", "target_document_id", name="uix_note_link" 

205 ), 

206 Index("idx_note_link_target", "target_document_id"), 

207 CheckConstraint( 

208 "source_document_id != target_document_id", 

209 name="ck_note_link_no_self", 

210 ), 

211 ) 

212 

213 def __repr__(self): 

214 src_id = ( 

215 self.source_document_id[:8] if self.source_document_id else "None" 

216 ) 

217 tgt_id = ( 

218 self.target_document_id[:8] if self.target_document_id else "None" 

219 ) 

220 return f"<NoteLink(source={src_id}, target={tgt_id})>" 

221 

222 

223class NoteResearch(Base): 

224 """ 

225 Junction table tracking research runs triggered from notes. 

226 Links notes (documents) to their research results. 

227 """ 

228 

229 __tablename__ = "note_research" 

230 

231 id = Column(Integer, primary_key=True, autoincrement=True) 

232 

233 # Foreign keys 

234 # document_id is indexed by the uix_note_research_display_order UNIQUE 

235 # (it leads with document_id), so no separate index is needed. 

236 document_id = Column( 

237 String(36), 

238 ForeignKey("documents.id", ondelete="CASCADE"), 

239 nullable=False, 

240 ) 

241 research_id = Column( 

242 String(36), 

243 ForeignKey("research_history.id", ondelete="CASCADE"), 

244 nullable=False, 

245 index=True, 

246 ) 

247 

248 # Display settings 

249 display_order = Column( 

250 Integer, default=0, server_default=text("0"), nullable=False 

251 ) 

252 is_collapsed = Column( 

253 Boolean, default=False, server_default=text("0"), nullable=False 

254 ) 

255 

256 # Timestamps 

257 created_at = Column( 

258 UtcDateTime, 

259 default=utcnow(), 

260 server_default=utcnow(), 

261 nullable=False, 

262 ) 

263 

264 # Relationships 

265 document = relationship( 

266 "Document", 

267 backref=backref("note_research_runs", passive_deletes=True), 

268 ) 

269 research = relationship( 

270 "ResearchHistory", 

271 backref=backref("note_context", passive_deletes=True), 

272 ) 

273 

274 # Ensure one entry per document-research pair, and prevent 

275 # duplicate display_order from a concurrent-insert race. 

276 # `link_research_to_note` computes MAX(display_order)+1 then INSERTs; 

277 # without the second UniqueConstraint two concurrent calls would 

278 # both observe the same MAX and insert with the same display_order. 

279 # The service-layer fix retries on IntegrityError; the constraint 

280 # is what makes the retry necessary (and safe). 

281 __table_args__ = ( 

282 UniqueConstraint( 

283 "document_id", "research_id", name="uix_note_research" 

284 ), 

285 # uix_note_research_display_order leads with document_id, so its 

286 # backing btree already serves every (document_id [, display_order]) 

287 # read — get_note_research's ORDER BY and link_research's 

288 # MAX(display_order) WHERE document_id. A separate 

289 # idx_note_research_order over the identical columns would be a 

290 # redundant second physical index (dead weight on the reorder 

291 # write path), so it is intentionally omitted — same reasoning the 

292 # note_links table applies to its source column. 

293 UniqueConstraint( 

294 "document_id", 

295 "display_order", 

296 name="uix_note_research_display_order", 

297 ), 

298 ) 

299 

300 def __repr__(self): 

301 doc_id = self.document_id[:8] if self.document_id else "None" 

302 res_id = self.research_id[:8] if self.research_id else "None" 

303 return f"<NoteResearch(document_id={doc_id}, research_id={res_id})>" 

304 

305 

306class NoteReference(Base): 

307 """Generic note → target reference with an optional quote anchor. 

308 

309 A row records that a note refers to a TARGET — a library document or 

310 a research run (exactly one, CHECK-enforced). With a ``quote`` it is 

311 an inline annotation: the quoted passage of the target's immutable 

312 rendered text is highlighted, with short ``prefix``/``suffix`` 

313 context disambiguating repeated phrases (Hypothesis-style 

314 TextQuoteSelector). Without a quote it is a plain "this note is 

315 about that target" link, powering the target page's Notes panel. 

316 

317 NoteResearch remains the note-side pinning of research runs (with 

318 per-note display ordering); this table is the target-side reference 

319 layer both documents and research share. 

320 """ 

321 

322 __tablename__ = "note_references" 

323 

324 id = Column(Integer, primary_key=True, autoincrement=True) 

325 

326 note_id = Column( 

327 String(36), 

328 ForeignKey("documents.id", ondelete="CASCADE"), 

329 nullable=False, 

330 index=True, 

331 ) 

332 target_document_id = Column( 

333 String(36), 

334 ForeignKey("documents.id", ondelete="CASCADE"), 

335 nullable=True, 

336 index=True, 

337 ) 

338 target_research_id = Column( 

339 String(36), 

340 ForeignKey("research_history.id", ondelete="CASCADE"), 

341 nullable=True, 

342 index=True, 

343 ) 

344 

345 # Anchor (NULL quote = anchor-less reference). 

346 quote = Column(Text, nullable=True) 

347 prefix = Column(Text, nullable=True) 

348 suffix = Column(Text, nullable=True) 

349 

350 created_at = Column( 

351 UtcDateTime, 

352 default=utcnow(), 

353 server_default=utcnow(), 

354 nullable=False, 

355 ) 

356 

357 note = relationship( 

358 "Document", 

359 foreign_keys=[note_id], 

360 backref=backref("outgoing_references", passive_deletes=True), 

361 ) 

362 target_document = relationship( 

363 "Document", 

364 foreign_keys=[target_document_id], 

365 backref=backref("note_annotations", passive_deletes=True), 

366 ) 

367 target_research = relationship( 

368 "ResearchHistory", 

369 backref=backref("note_annotations", passive_deletes=True), 

370 ) 

371 

372 __table_args__ = ( 

373 # Exactly one target — mirrors ck_note_reference_single_target in 

374 # migration 0022 (three-way parity guarded by tests, same house 

375 # style as the note_syntheses enum CHECK). 

376 CheckConstraint( 

377 "(target_document_id IS NOT NULL) + " 

378 "(target_research_id IS NOT NULL) = 1", 

379 name="ck_note_reference_single_target", 

380 ), 

381 ) 

382 

383 def __repr__(self): 

384 note_id = self.note_id[:8] if self.note_id else "None" 

385 target = self.target_document_id or self.target_research_id or "None" 

386 return ( 

387 f"<NoteReference(note={note_id}, target={target[:8]}, " 

388 f"anchored={self.quote is not None})>" 

389 ) 

390 

391 

392class NoteSynthesis(Base): 

393 """ 

394 Tracks note synthesis operations (merge, compare, summarize). 

395 Links synthesized notes to their source notes via NoteSynthesisSource. 

396 """ 

397 

398 __tablename__ = "note_syntheses" 

399 

400 id = Column(Integer, primary_key=True, autoincrement=True) 

401 

402 # The resulting synthesized note (document). 

403 # `ondelete='SET NULL'` (not CASCADE) preserves the audit record even 

404 # after the result note is deleted. CASCADE was considered because no 

405 # route currently reads syntheses, but destroying the record — and its 

406 # junction rows tying each source note to this operation — loses more 

407 # than it's worth: a future "what AI operations have I run?" UI can 

408 # read NULL-result rows meaningfully. 

409 result_document_id = Column( 

410 String(36), 

411 ForeignKey("documents.id", ondelete="SET NULL"), 

412 nullable=True, 

413 index=True, 

414 ) 

415 

416 # Type of synthesis performed. Enum with `values_callable` mirrors the 

417 # house style used by NoteChangeType (and DocumentStatus / EmbeddingProvider 

418 # elsewhere): suppresses SQLAlchemy's auto-CHECK so the sole DB-level 

419 # enforcement is the explicit CheckConstraint in __table_args__. When 

420 # NoteSynthesisType grows, update both literals; TestEnumCheckParity 

421 # in tests/database/test_migration_0021_note_tables.py guards the parity. 

422 synthesis_type = Column( 

423 Enum( 

424 NoteSynthesisType, 

425 values_callable=lambda obj: [e.value for e in obj], 

426 name="note_synthesis_type", 

427 ), 

428 nullable=False, 

429 ) 

430 

431 # Timestamps 

432 created_at = Column( 

433 UtcDateTime, 

434 default=utcnow(), 

435 server_default=utcnow(), 

436 nullable=False, 

437 ) 

438 

439 # Relationships 

440 result_document = relationship( 

441 "Document", foreign_keys=[result_document_id] 

442 ) 

443 sources = relationship( 

444 "NoteSynthesisSource", 

445 cascade="all, delete-orphan", 

446 backref=backref("synthesis"), 

447 ) 

448 

449 __table_args__ = ( 

450 # Mirror NoteSynthesisType members above. The ORM Enum() with 

451 # values_callable suppresses SQLAlchemy's auto-CHECK, so declare 

452 # it explicitly here — parity with the migration. 

453 CheckConstraint( 

454 "synthesis_type IN ('merge', 'summarize', 'compare')", 

455 name="ck_note_synthesis_type", 

456 ), 

457 ) 

458 

459 def __repr__(self): 

460 return f"<NoteSynthesis(type={self.synthesis_type}, sources={len(self.sources)})>" 

461 

462 

463class NoteSynthesisSource(Base): 

464 """ 

465 Junction table linking a NoteSynthesis to its source Documents. 

466 

467 Replaces an earlier JSON-list column on NoteSynthesis (source_document_ids) 

468 that lacked FK integrity — the author's original TODO on migration 0006. 

469 Do NOT revert to a JSON list "for simplicity": it re-opens the stale-ID 

470 class of bug, where deleting a source note leaves dangling references in 

471 the synthesis record forever. 

472 """ 

473 

474 __tablename__ = "note_synthesis_sources" 

475 

476 id = Column(Integer, primary_key=True, autoincrement=True) 

477 

478 synthesis_id = Column( 

479 Integer, 

480 ForeignKey("note_syntheses.id", ondelete="CASCADE"), 

481 nullable=False, 

482 index=True, 

483 ) 

484 source_document_id = Column( 

485 String(36), 

486 ForeignKey("documents.id", ondelete="CASCADE"), 

487 nullable=False, 

488 index=True, 

489 ) 

490 

491 created_at = Column( 

492 UtcDateTime, 

493 default=utcnow(), 

494 server_default=utcnow(), 

495 nullable=False, 

496 ) 

497 

498 source_document = relationship( 

499 "Document", foreign_keys=[source_document_id] 

500 ) 

501 

502 __table_args__ = ( 

503 UniqueConstraint( 

504 "synthesis_id", 

505 "source_document_id", 

506 name="uix_note_synthesis_source", 

507 ), 

508 ) 

509 

510 def __repr__(self): 

511 syn_id = self.synthesis_id if self.synthesis_id is not None else "None" 

512 src_id = ( 

513 self.source_document_id[:8] if self.source_document_id else "None" 

514 ) 

515 return f"<NoteSynthesisSource(synthesis={syn_id}, source={src_id})>"