truthound-dashboard 1.0.2__py3-none-any.whl → 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. truthound_dashboard/api/catalog.py +343 -0
  2. truthound_dashboard/api/collaboration.py +148 -0
  3. truthound_dashboard/api/glossary.py +329 -0
  4. truthound_dashboard/api/router.py +29 -0
  5. truthound_dashboard/cli.py +397 -0
  6. truthound_dashboard/core/__init__.py +12 -0
  7. truthound_dashboard/core/phase5/__init__.py +17 -0
  8. truthound_dashboard/core/phase5/activity.py +144 -0
  9. truthound_dashboard/core/phase5/catalog.py +868 -0
  10. truthound_dashboard/core/phase5/collaboration.py +305 -0
  11. truthound_dashboard/core/phase5/glossary.py +828 -0
  12. truthound_dashboard/db/__init__.py +37 -0
  13. truthound_dashboard/db/models.py +693 -0
  14. truthound_dashboard/schemas/__init__.py +114 -0
  15. truthound_dashboard/schemas/catalog.py +352 -0
  16. truthound_dashboard/schemas/collaboration.py +169 -0
  17. truthound_dashboard/schemas/glossary.py +349 -0
  18. truthound_dashboard/translate/__init__.py +61 -0
  19. truthound_dashboard/translate/config_updater.py +327 -0
  20. truthound_dashboard/translate/exceptions.py +98 -0
  21. truthound_dashboard/translate/providers/__init__.py +49 -0
  22. truthound_dashboard/translate/providers/anthropic.py +135 -0
  23. truthound_dashboard/translate/providers/base.py +225 -0
  24. truthound_dashboard/translate/providers/mistral.py +138 -0
  25. truthound_dashboard/translate/providers/ollama.py +226 -0
  26. truthound_dashboard/translate/providers/openai.py +187 -0
  27. truthound_dashboard/translate/providers/registry.py +217 -0
  28. truthound_dashboard/translate/translator.py +443 -0
  29. {truthound_dashboard-1.0.2.dist-info → truthound_dashboard-1.2.0.dist-info}/METADATA +123 -4
  30. {truthound_dashboard-1.0.2.dist-info → truthound_dashboard-1.2.0.dist-info}/RECORD +33 -11
  31. {truthound_dashboard-1.0.2.dist-info → truthound_dashboard-1.2.0.dist-info}/WHEEL +0 -0
  32. {truthound_dashboard-1.0.2.dist-info → truthound_dashboard-1.2.0.dist-info}/entry_points.txt +0 -0
  33. {truthound_dashboard-1.0.2.dist-info → truthound_dashboard-1.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,868 @@
1
+ """Catalog service for Phase 5.
2
+
3
+ Provides business logic for managing data catalog assets,
4
+ columns, and tags.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+ from typing import Any
11
+
12
+ from sqlalchemy import or_, select
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+
15
+ from truthound_dashboard.db import (
16
+ ActivityAction,
17
+ AssetColumn,
18
+ AssetTag,
19
+ AssetType,
20
+ BaseRepository,
21
+ CatalogAsset,
22
+ GlossaryTerm,
23
+ ResourceType,
24
+ Source,
25
+ )
26
+
27
+ from .activity import ActivityLogger
28
+
29
+
30
+ # =============================================================================
31
+ # Repositories
32
+ # =============================================================================
33
+
34
+
35
+ class AssetRepository(BaseRepository[CatalogAsset]):
36
+ """Repository for CatalogAsset model operations."""
37
+
38
+ model = CatalogAsset
39
+
40
+ async def search(
41
+ self,
42
+ *,
43
+ query: str | None = None,
44
+ asset_type: str | None = None,
45
+ source_id: str | None = None,
46
+ offset: int = 0,
47
+ limit: int = 100,
48
+ ) -> Sequence[CatalogAsset]:
49
+ """Search assets with filters.
50
+
51
+ Args:
52
+ query: Search query (name or description).
53
+ asset_type: Filter by asset type.
54
+ source_id: Filter by data source.
55
+ offset: Number to skip.
56
+ limit: Maximum to return.
57
+
58
+ Returns:
59
+ Sequence of matching assets.
60
+ """
61
+ filters = []
62
+
63
+ if query:
64
+ search_pattern = f"%{query}%"
65
+ filters.append(
66
+ or_(
67
+ CatalogAsset.name.ilike(search_pattern),
68
+ CatalogAsset.description.ilike(search_pattern),
69
+ )
70
+ )
71
+
72
+ if asset_type:
73
+ filters.append(CatalogAsset.asset_type == asset_type)
74
+
75
+ if source_id:
76
+ filters.append(CatalogAsset.source_id == source_id)
77
+
78
+ return await self.list(
79
+ offset=offset,
80
+ limit=limit,
81
+ filters=filters if filters else None,
82
+ )
83
+
84
+ async def count_filtered(
85
+ self,
86
+ *,
87
+ query: str | None = None,
88
+ asset_type: str | None = None,
89
+ source_id: str | None = None,
90
+ ) -> int:
91
+ """Count assets matching filters.
92
+
93
+ Args:
94
+ query: Search query.
95
+ asset_type: Filter by asset type.
96
+ source_id: Filter by data source.
97
+
98
+ Returns:
99
+ Total count.
100
+ """
101
+ filters = []
102
+
103
+ if query:
104
+ search_pattern = f"%{query}%"
105
+ filters.append(
106
+ or_(
107
+ CatalogAsset.name.ilike(search_pattern),
108
+ CatalogAsset.description.ilike(search_pattern),
109
+ )
110
+ )
111
+
112
+ if asset_type:
113
+ filters.append(CatalogAsset.asset_type == asset_type)
114
+
115
+ if source_id:
116
+ filters.append(CatalogAsset.source_id == source_id)
117
+
118
+ return await self.count(filters if filters else None)
119
+
120
+ async def get_by_source(
121
+ self,
122
+ source_id: str,
123
+ *,
124
+ limit: int = 100,
125
+ ) -> Sequence[CatalogAsset]:
126
+ """Get assets for a data source.
127
+
128
+ Args:
129
+ source_id: Data source ID.
130
+ limit: Maximum to return.
131
+
132
+ Returns:
133
+ Sequence of assets.
134
+ """
135
+ return await self.list(
136
+ limit=limit,
137
+ filters=[CatalogAsset.source_id == source_id],
138
+ )
139
+
140
+
141
+ class ColumnRepository(BaseRepository[AssetColumn]):
142
+ """Repository for AssetColumn model operations."""
143
+
144
+ model = AssetColumn
145
+
146
+ async def get_for_asset(
147
+ self,
148
+ asset_id: str,
149
+ *,
150
+ limit: int = 500,
151
+ ) -> Sequence[AssetColumn]:
152
+ """Get columns for an asset.
153
+
154
+ Args:
155
+ asset_id: Asset ID.
156
+ limit: Maximum to return.
157
+
158
+ Returns:
159
+ Sequence of columns.
160
+ """
161
+ return await self.list(
162
+ limit=limit,
163
+ filters=[AssetColumn.asset_id == asset_id],
164
+ order_by=AssetColumn.name,
165
+ )
166
+
167
+ async def get_by_term(
168
+ self,
169
+ term_id: str,
170
+ *,
171
+ limit: int = 100,
172
+ ) -> Sequence[AssetColumn]:
173
+ """Get columns mapped to a term.
174
+
175
+ Args:
176
+ term_id: Term ID.
177
+ limit: Maximum to return.
178
+
179
+ Returns:
180
+ Sequence of columns.
181
+ """
182
+ return await self.list(
183
+ limit=limit,
184
+ filters=[AssetColumn.term_id == term_id],
185
+ )
186
+
187
+
188
+ class TagRepository(BaseRepository[AssetTag]):
189
+ """Repository for AssetTag model operations."""
190
+
191
+ model = AssetTag
192
+
193
+ async def get_for_asset(
194
+ self,
195
+ asset_id: str,
196
+ ) -> list[AssetTag]:
197
+ """Get tags for an asset.
198
+
199
+ Args:
200
+ asset_id: Asset ID.
201
+
202
+ Returns:
203
+ List of tags.
204
+ """
205
+ result = await self.session.execute(
206
+ select(AssetTag)
207
+ .where(AssetTag.asset_id == asset_id)
208
+ .order_by(AssetTag.tag_name)
209
+ )
210
+ return list(result.scalars().all())
211
+
212
+ async def get_existing(
213
+ self,
214
+ asset_id: str,
215
+ tag_name: str,
216
+ ) -> AssetTag | None:
217
+ """Get existing tag by name.
218
+
219
+ Args:
220
+ asset_id: Asset ID.
221
+ tag_name: Tag name.
222
+
223
+ Returns:
224
+ Existing tag or None.
225
+ """
226
+ result = await self.session.execute(
227
+ select(AssetTag).where(
228
+ AssetTag.asset_id == asset_id,
229
+ AssetTag.tag_name == tag_name,
230
+ )
231
+ )
232
+ return result.scalar_one_or_none()
233
+
234
+
235
+ # =============================================================================
236
+ # Service
237
+ # =============================================================================
238
+
239
+
240
+ class CatalogService:
241
+ """Service for managing data catalog.
242
+
243
+ Handles asset, column, and tag CRUD operations
244
+ with activity logging.
245
+ """
246
+
247
+ def __init__(self, session: AsyncSession) -> None:
248
+ """Initialize service.
249
+
250
+ Args:
251
+ session: Database session.
252
+ """
253
+ self.session = session
254
+ self.asset_repo = AssetRepository(session)
255
+ self.column_repo = ColumnRepository(session)
256
+ self.tag_repo = TagRepository(session)
257
+ self.activity_logger = ActivityLogger(session)
258
+
259
+ # =========================================================================
260
+ # Asset Operations
261
+ # =========================================================================
262
+
263
+ async def list_assets(
264
+ self,
265
+ *,
266
+ query: str | None = None,
267
+ asset_type: str | None = None,
268
+ source_id: str | None = None,
269
+ offset: int = 0,
270
+ limit: int = 100,
271
+ ) -> tuple[Sequence[CatalogAsset], int]:
272
+ """List assets with filters.
273
+
274
+ Args:
275
+ query: Search query.
276
+ asset_type: Filter by type.
277
+ source_id: Filter by data source.
278
+ offset: Number to skip.
279
+ limit: Maximum to return.
280
+
281
+ Returns:
282
+ Tuple of (assets, total_count).
283
+ """
284
+ assets = await self.asset_repo.search(
285
+ query=query,
286
+ asset_type=asset_type,
287
+ source_id=source_id,
288
+ offset=offset,
289
+ limit=limit,
290
+ )
291
+ total = await self.asset_repo.count_filtered(
292
+ query=query,
293
+ asset_type=asset_type,
294
+ source_id=source_id,
295
+ )
296
+ return assets, total
297
+
298
+ async def get_asset(self, asset_id: str) -> CatalogAsset | None:
299
+ """Get asset by ID.
300
+
301
+ Args:
302
+ asset_id: Asset ID.
303
+
304
+ Returns:
305
+ Asset or None.
306
+ """
307
+ return await self.asset_repo.get_by_id(asset_id)
308
+
309
+ async def create_asset(
310
+ self,
311
+ *,
312
+ name: str,
313
+ asset_type: str = AssetType.TABLE.value,
314
+ source_id: str | None = None,
315
+ description: str | None = None,
316
+ owner_id: str | None = None,
317
+ columns: list[dict[str, Any]] | None = None,
318
+ tags: list[dict[str, Any]] | None = None,
319
+ actor_id: str | None = None,
320
+ ) -> CatalogAsset:
321
+ """Create a new asset.
322
+
323
+ Args:
324
+ name: Asset name.
325
+ asset_type: Type of asset.
326
+ source_id: Optional data source ID.
327
+ description: Optional description.
328
+ owner_id: Owner identifier.
329
+ columns: Initial columns to create.
330
+ tags: Initial tags to add.
331
+ actor_id: User creating the asset.
332
+
333
+ Returns:
334
+ Created asset.
335
+
336
+ Raises:
337
+ ValueError: If source not found.
338
+ """
339
+ # Validate source if provided
340
+ if source_id:
341
+ result = await self.session.execute(
342
+ select(Source).where(Source.id == source_id)
343
+ )
344
+ source = result.scalar_one_or_none()
345
+ if not source:
346
+ raise ValueError(f"Data source '{source_id}' not found")
347
+
348
+ asset = await self.asset_repo.create(
349
+ name=name,
350
+ asset_type=asset_type,
351
+ source_id=source_id,
352
+ description=description,
353
+ owner_id=owner_id,
354
+ )
355
+
356
+ # Create initial columns
357
+ if columns:
358
+ for col_data in columns:
359
+ await self.column_repo.create(
360
+ asset_id=asset.id,
361
+ **col_data,
362
+ )
363
+
364
+ # Create initial tags
365
+ if tags:
366
+ for tag_data in tags:
367
+ await self.tag_repo.create(
368
+ asset_id=asset.id,
369
+ **tag_data,
370
+ )
371
+
372
+ await self.session.flush()
373
+ await self.session.refresh(asset)
374
+
375
+ await self.activity_logger.log(
376
+ ResourceType.ASSET,
377
+ asset.id,
378
+ ActivityAction.CREATED,
379
+ actor_id=actor_id,
380
+ description=f"Created asset: {asset.name}",
381
+ )
382
+
383
+ return asset
384
+
385
+ async def update_asset(
386
+ self,
387
+ asset_id: str,
388
+ *,
389
+ name: str | None = None,
390
+ asset_type: str | None = None,
391
+ source_id: str | None = None,
392
+ description: str | None = None,
393
+ owner_id: str | None = None,
394
+ quality_score: float | None = None,
395
+ actor_id: str | None = None,
396
+ ) -> CatalogAsset | None:
397
+ """Update an asset.
398
+
399
+ Args:
400
+ asset_id: Asset ID.
401
+ name: New name.
402
+ asset_type: New type.
403
+ source_id: New source ID.
404
+ description: New description.
405
+ owner_id: New owner.
406
+ quality_score: New quality score.
407
+ actor_id: User updating the asset.
408
+
409
+ Returns:
410
+ Updated asset or None.
411
+
412
+ Raises:
413
+ ValueError: If source not found.
414
+ """
415
+ asset = await self.asset_repo.get_by_id(asset_id)
416
+ if not asset:
417
+ return None
418
+
419
+ changes = {}
420
+
421
+ if name is not None and name != asset.name:
422
+ changes["name"] = {"old": asset.name, "new": name}
423
+ asset.name = name
424
+
425
+ if asset_type is not None and asset_type != asset.asset_type:
426
+ changes["asset_type"] = {"old": asset.asset_type, "new": asset_type}
427
+ asset.asset_type = asset_type
428
+
429
+ if source_id is not None and source_id != asset.source_id:
430
+ if source_id:
431
+ result = await self.session.execute(
432
+ select(Source).where(Source.id == source_id)
433
+ )
434
+ source = result.scalar_one_or_none()
435
+ if not source:
436
+ raise ValueError(f"Data source '{source_id}' not found")
437
+ changes["source_id"] = {"old": asset.source_id, "new": source_id}
438
+ asset.source_id = source_id
439
+
440
+ if description is not None and description != asset.description:
441
+ changes["description"] = {"old": asset.description, "new": description}
442
+ asset.description = description
443
+
444
+ if owner_id is not None and owner_id != asset.owner_id:
445
+ changes["owner_id"] = {"old": asset.owner_id, "new": owner_id}
446
+ asset.owner_id = owner_id
447
+
448
+ if quality_score is not None and quality_score != asset.quality_score:
449
+ changes["quality_score"] = {"old": asset.quality_score, "new": quality_score}
450
+ asset.update_quality_score(quality_score)
451
+
452
+ if changes:
453
+ await self.session.flush()
454
+ await self.session.refresh(asset)
455
+ await self.activity_logger.log(
456
+ ResourceType.ASSET,
457
+ asset.id,
458
+ ActivityAction.UPDATED,
459
+ actor_id=actor_id,
460
+ description=f"Updated asset: {asset.name}",
461
+ metadata={"changes": changes},
462
+ )
463
+
464
+ return asset
465
+
466
+ async def delete_asset(
467
+ self,
468
+ asset_id: str,
469
+ *,
470
+ actor_id: str | None = None,
471
+ ) -> bool:
472
+ """Delete an asset.
473
+
474
+ Args:
475
+ asset_id: Asset ID.
476
+ actor_id: User deleting the asset.
477
+
478
+ Returns:
479
+ True if deleted.
480
+ """
481
+ asset = await self.asset_repo.get_by_id(asset_id)
482
+ if not asset:
483
+ return False
484
+
485
+ asset_name = asset.name
486
+ deleted = await self.asset_repo.delete(asset_id)
487
+
488
+ if deleted:
489
+ await self.activity_logger.log(
490
+ ResourceType.ASSET,
491
+ asset_id,
492
+ ActivityAction.DELETED,
493
+ actor_id=actor_id,
494
+ description=f"Deleted asset: {asset_name}",
495
+ )
496
+
497
+ return deleted
498
+
499
+ # =========================================================================
500
+ # Column Operations
501
+ # =========================================================================
502
+
503
+ async def get_columns(
504
+ self,
505
+ asset_id: str,
506
+ ) -> Sequence[AssetColumn]:
507
+ """Get columns for an asset.
508
+
509
+ Args:
510
+ asset_id: Asset ID.
511
+
512
+ Returns:
513
+ Sequence of columns.
514
+ """
515
+ return await self.column_repo.get_for_asset(asset_id)
516
+
517
+ async def get_column(self, column_id: str) -> AssetColumn | None:
518
+ """Get column by ID.
519
+
520
+ Args:
521
+ column_id: Column ID.
522
+
523
+ Returns:
524
+ Column or None.
525
+ """
526
+ return await self.column_repo.get_by_id(column_id)
527
+
528
+ async def create_column(
529
+ self,
530
+ asset_id: str,
531
+ *,
532
+ name: str,
533
+ data_type: str | None = None,
534
+ description: str | None = None,
535
+ is_nullable: bool = True,
536
+ is_primary_key: bool = False,
537
+ sensitivity_level: str | None = None,
538
+ actor_id: str | None = None,
539
+ ) -> AssetColumn:
540
+ """Create a new column.
541
+
542
+ Args:
543
+ asset_id: Asset ID.
544
+ name: Column name.
545
+ data_type: Data type.
546
+ description: Description.
547
+ is_nullable: Whether nullable.
548
+ is_primary_key: Whether PK.
549
+ sensitivity_level: Sensitivity level.
550
+ actor_id: User creating the column.
551
+
552
+ Returns:
553
+ Created column.
554
+
555
+ Raises:
556
+ ValueError: If asset not found.
557
+ """
558
+ asset = await self.asset_repo.get_by_id(asset_id)
559
+ if not asset:
560
+ raise ValueError(f"Asset '{asset_id}' not found")
561
+
562
+ column = await self.column_repo.create(
563
+ asset_id=asset_id,
564
+ name=name,
565
+ data_type=data_type,
566
+ description=description,
567
+ is_nullable=is_nullable,
568
+ is_primary_key=is_primary_key,
569
+ sensitivity_level=sensitivity_level,
570
+ )
571
+
572
+ await self.activity_logger.log(
573
+ ResourceType.COLUMN,
574
+ column.id,
575
+ ActivityAction.CREATED,
576
+ actor_id=actor_id,
577
+ description=f"Created column: {asset.name}.{column.name}",
578
+ )
579
+
580
+ return column
581
+
582
+ async def update_column(
583
+ self,
584
+ column_id: str,
585
+ *,
586
+ name: str | None = None,
587
+ data_type: str | None = None,
588
+ description: str | None = None,
589
+ is_nullable: bool | None = None,
590
+ is_primary_key: bool | None = None,
591
+ sensitivity_level: str | None = None,
592
+ actor_id: str | None = None,
593
+ ) -> AssetColumn | None:
594
+ """Update a column.
595
+
596
+ Args:
597
+ column_id: Column ID.
598
+ name: New name.
599
+ data_type: New data type.
600
+ description: New description.
601
+ is_nullable: New nullable setting.
602
+ is_primary_key: New PK setting.
603
+ sensitivity_level: New sensitivity level.
604
+ actor_id: User updating the column.
605
+
606
+ Returns:
607
+ Updated column or None.
608
+ """
609
+ column = await self.column_repo.get_by_id(column_id)
610
+ if not column:
611
+ return None
612
+
613
+ changes = {}
614
+
615
+ if name is not None and name != column.name:
616
+ changes["name"] = {"old": column.name, "new": name}
617
+ column.name = name
618
+
619
+ if data_type is not None and data_type != column.data_type:
620
+ changes["data_type"] = {"old": column.data_type, "new": data_type}
621
+ column.data_type = data_type
622
+
623
+ if description is not None and description != column.description:
624
+ changes["description"] = {"old": column.description, "new": description}
625
+ column.description = description
626
+
627
+ if is_nullable is not None and is_nullable != column.is_nullable:
628
+ changes["is_nullable"] = {"old": column.is_nullable, "new": is_nullable}
629
+ column.is_nullable = is_nullable
630
+
631
+ if is_primary_key is not None and is_primary_key != column.is_primary_key:
632
+ changes["is_primary_key"] = {"old": column.is_primary_key, "new": is_primary_key}
633
+ column.is_primary_key = is_primary_key
634
+
635
+ if sensitivity_level is not None and sensitivity_level != column.sensitivity_level:
636
+ changes["sensitivity_level"] = {"old": column.sensitivity_level, "new": sensitivity_level}
637
+ column.sensitivity_level = sensitivity_level
638
+
639
+ if changes:
640
+ await self.session.flush()
641
+ await self.session.refresh(column)
642
+ await self.activity_logger.log(
643
+ ResourceType.COLUMN,
644
+ column.id,
645
+ ActivityAction.UPDATED,
646
+ actor_id=actor_id,
647
+ description=f"Updated column: {column.name}",
648
+ metadata={"changes": changes},
649
+ )
650
+
651
+ return column
652
+
653
+ async def delete_column(
654
+ self,
655
+ column_id: str,
656
+ *,
657
+ actor_id: str | None = None,
658
+ ) -> bool:
659
+ """Delete a column.
660
+
661
+ Args:
662
+ column_id: Column ID.
663
+ actor_id: User deleting the column.
664
+
665
+ Returns:
666
+ True if deleted.
667
+ """
668
+ column = await self.column_repo.get_by_id(column_id)
669
+ if not column:
670
+ return False
671
+
672
+ column_name = column.name
673
+ deleted = await self.column_repo.delete(column_id)
674
+
675
+ if deleted:
676
+ await self.activity_logger.log(
677
+ ResourceType.COLUMN,
678
+ column_id,
679
+ ActivityAction.DELETED,
680
+ actor_id=actor_id,
681
+ description=f"Deleted column: {column_name}",
682
+ )
683
+
684
+ return deleted
685
+
686
+ async def map_column_to_term(
687
+ self,
688
+ column_id: str,
689
+ term_id: str,
690
+ *,
691
+ actor_id: str | None = None,
692
+ ) -> AssetColumn | None:
693
+ """Map a column to a glossary term.
694
+
695
+ Args:
696
+ column_id: Column ID.
697
+ term_id: Term ID.
698
+ actor_id: User creating the mapping.
699
+
700
+ Returns:
701
+ Updated column or None.
702
+
703
+ Raises:
704
+ ValueError: If term not found.
705
+ """
706
+ column = await self.column_repo.get_by_id(column_id)
707
+ if not column:
708
+ return None
709
+
710
+ # Validate term exists
711
+ result = await self.session.execute(
712
+ select(GlossaryTerm).where(GlossaryTerm.id == term_id)
713
+ )
714
+ term = result.scalar_one_or_none()
715
+ if not term:
716
+ raise ValueError(f"Term '{term_id}' not found")
717
+
718
+ column.map_to_term(term_id)
719
+ await self.session.flush()
720
+ await self.session.refresh(column)
721
+
722
+ await self.activity_logger.log(
723
+ ResourceType.COLUMN,
724
+ column.id,
725
+ ActivityAction.MAPPED,
726
+ actor_id=actor_id,
727
+ description=f"Mapped {column.name} to term: {term.name}",
728
+ metadata={"term_id": term_id, "term_name": term.name},
729
+ )
730
+
731
+ return column
732
+
733
+ async def unmap_column_from_term(
734
+ self,
735
+ column_id: str,
736
+ *,
737
+ actor_id: str | None = None,
738
+ ) -> AssetColumn | None:
739
+ """Remove term mapping from a column.
740
+
741
+ Args:
742
+ column_id: Column ID.
743
+ actor_id: User removing the mapping.
744
+
745
+ Returns:
746
+ Updated column or None.
747
+ """
748
+ column = await self.column_repo.get_by_id(column_id)
749
+ if not column:
750
+ return None
751
+
752
+ if column.term_id is None:
753
+ return column
754
+
755
+ column.unmap_term()
756
+ await self.session.flush()
757
+ await self.session.refresh(column)
758
+
759
+ await self.activity_logger.log(
760
+ ResourceType.COLUMN,
761
+ column.id,
762
+ ActivityAction.UNMAPPED,
763
+ actor_id=actor_id,
764
+ description=f"Removed term mapping from: {column.name}",
765
+ )
766
+
767
+ return column
768
+
769
+ # =========================================================================
770
+ # Tag Operations
771
+ # =========================================================================
772
+
773
+ async def get_tags(
774
+ self,
775
+ asset_id: str,
776
+ ) -> list[AssetTag]:
777
+ """Get tags for an asset.
778
+
779
+ Args:
780
+ asset_id: Asset ID.
781
+
782
+ Returns:
783
+ List of tags.
784
+ """
785
+ return await self.tag_repo.get_for_asset(asset_id)
786
+
787
+ async def add_tag(
788
+ self,
789
+ asset_id: str,
790
+ *,
791
+ tag_name: str,
792
+ tag_value: str | None = None,
793
+ actor_id: str | None = None,
794
+ ) -> AssetTag:
795
+ """Add a tag to an asset.
796
+
797
+ Args:
798
+ asset_id: Asset ID.
799
+ tag_name: Tag name.
800
+ tag_value: Optional tag value.
801
+ actor_id: User adding the tag.
802
+
803
+ Returns:
804
+ Created tag.
805
+
806
+ Raises:
807
+ ValueError: If asset not found or tag exists.
808
+ """
809
+ asset = await self.asset_repo.get_by_id(asset_id)
810
+ if not asset:
811
+ raise ValueError(f"Asset '{asset_id}' not found")
812
+
813
+ # Check for existing tag
814
+ existing = await self.tag_repo.get_existing(asset_id, tag_name)
815
+ if existing:
816
+ raise ValueError(f"Tag '{tag_name}' already exists on this asset")
817
+
818
+ tag = await self.tag_repo.create(
819
+ asset_id=asset_id,
820
+ tag_name=tag_name.strip().lower(),
821
+ tag_value=tag_value,
822
+ )
823
+
824
+ await self.activity_logger.log(
825
+ ResourceType.ASSET,
826
+ asset_id,
827
+ "tag_added",
828
+ actor_id=actor_id,
829
+ description=f"Added tag: {tag_name}",
830
+ metadata={"tag_name": tag_name, "tag_value": tag_value},
831
+ )
832
+
833
+ return tag
834
+
835
+ async def remove_tag(
836
+ self,
837
+ tag_id: str,
838
+ *,
839
+ actor_id: str | None = None,
840
+ ) -> bool:
841
+ """Remove a tag.
842
+
843
+ Args:
844
+ tag_id: Tag ID.
845
+ actor_id: User removing the tag.
846
+
847
+ Returns:
848
+ True if removed.
849
+ """
850
+ tag = await self.tag_repo.get_by_id(tag_id)
851
+ if not tag:
852
+ return False
853
+
854
+ asset_id = tag.asset_id
855
+ tag_name = tag.tag_name
856
+ deleted = await self.tag_repo.delete(tag_id)
857
+
858
+ if deleted:
859
+ await self.activity_logger.log(
860
+ ResourceType.ASSET,
861
+ asset_id,
862
+ "tag_removed",
863
+ actor_id=actor_id,
864
+ description=f"Removed tag: {tag_name}",
865
+ metadata={"tag_name": tag_name},
866
+ )
867
+
868
+ return deleted