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
@@ -10,10 +10,14 @@ Example:
10
10
 
11
11
  # Via truthound CLI plugin
12
12
  truthound serve --port 8765
13
+
14
+ # Translate UI to additional languages
15
+ truthound translate -l ja,zh,de -p openai
13
16
  """
14
17
 
15
18
  from __future__ import annotations
16
19
 
20
+ import asyncio
17
21
  import webbrowser
18
22
  from pathlib import Path
19
23
  from typing import Annotated
@@ -21,6 +25,8 @@ from typing import Annotated
21
25
  import typer
22
26
  from rich.console import Console
23
27
  from rich.panel import Panel
28
+ from rich.progress import Progress, SpinnerColumn, TextColumn
29
+ from rich.table import Table
24
30
 
25
31
  from truthound_dashboard import __version__
26
32
 
@@ -179,6 +185,355 @@ def info() -> None:
179
185
  )
180
186
 
181
187
 
188
+ @app.command()
189
+ def translate(
190
+ languages: Annotated[
191
+ str | None,
192
+ typer.Option(
193
+ "--languages",
194
+ "-l",
195
+ help="Target languages (comma-separated, e.g., ja,zh,de)",
196
+ ),
197
+ ] = None,
198
+ provider: Annotated[
199
+ str | None,
200
+ typer.Option(
201
+ "--provider",
202
+ "-p",
203
+ help="AI provider (openai, anthropic, ollama, mistral). Auto-detected if not specified.",
204
+ ),
205
+ ] = None,
206
+ model: Annotated[
207
+ str | None,
208
+ typer.Option(
209
+ "--model",
210
+ "-m",
211
+ help="Model name (e.g., gpt-4o, claude-sonnet-4-20250514). Uses provider default if not specified.",
212
+ ),
213
+ ] = None,
214
+ frontend_dir: Annotated[
215
+ Path | None,
216
+ typer.Option(
217
+ "--frontend-dir",
218
+ "-f",
219
+ help="Path to frontend directory. Auto-detected if not specified.",
220
+ exists=True,
221
+ file_okay=False,
222
+ resolve_path=True,
223
+ ),
224
+ ] = None,
225
+ dry_run: Annotated[
226
+ bool,
227
+ typer.Option(
228
+ "--dry-run",
229
+ help="Show what would be translated without making changes",
230
+ ),
231
+ ] = False,
232
+ list_providers: Annotated[
233
+ bool,
234
+ typer.Option(
235
+ "--list-providers",
236
+ help="List available AI providers and exit",
237
+ ),
238
+ ] = False,
239
+ list_languages: Annotated[
240
+ bool,
241
+ typer.Option(
242
+ "--list-languages",
243
+ help="List supported languages and exit",
244
+ ),
245
+ ] = False,
246
+ ) -> None:
247
+ """Translate UI to additional languages using AI.
248
+
249
+ This command uses AI providers to translate Intlayer content files
250
+ to new languages. Supports OpenAI, Anthropic, Ollama (local), and Mistral.
251
+
252
+ Examples:
253
+ # Translate to Japanese, Chinese, and German using OpenAI
254
+ truthound translate -l ja,zh,de -p openai
255
+
256
+ # Auto-detect provider based on environment variables
257
+ truthound translate -l ja,zh
258
+
259
+ # Use local Ollama (no API key needed)
260
+ truthound translate -l ja -p ollama
261
+
262
+ # Dry run to see what would be translated
263
+ truthound translate -l ja --dry-run
264
+
265
+ # List available providers
266
+ truthound translate --list-providers
267
+
268
+ # List supported languages
269
+ truthound translate --list-languages
270
+ """
271
+ # Handle list options first
272
+ if list_providers:
273
+ _show_providers()
274
+ raise typer.Exit()
275
+
276
+ if list_languages:
277
+ _show_languages()
278
+ raise typer.Exit()
279
+
280
+ # Require languages for actual translation
281
+ if not languages:
282
+ console.print("[red]Error:[/red] --languages / -l is required for translation")
283
+ console.print("Use --list-languages to see supported languages")
284
+ raise typer.Exit(1)
285
+
286
+ # Parse languages
287
+ target_langs = [lang.strip().lower() for lang in languages.split(",")]
288
+ if not target_langs:
289
+ console.print("[red]Error:[/red] No languages specified")
290
+ raise typer.Exit(1)
291
+
292
+ # Find frontend directory
293
+ if frontend_dir is None:
294
+ frontend_dir = _find_frontend_dir()
295
+ if frontend_dir is None:
296
+ console.print(
297
+ "[red]Error:[/red] Could not find frontend directory. "
298
+ "Please specify with --frontend-dir"
299
+ )
300
+ raise typer.Exit(1)
301
+
302
+ # Run translation
303
+ asyncio.run(
304
+ _run_translation(
305
+ target_langs=target_langs,
306
+ provider_name=provider,
307
+ model_name=model,
308
+ frontend_dir=frontend_dir,
309
+ dry_run=dry_run,
310
+ )
311
+ )
312
+
313
+
314
+ def _show_providers() -> None:
315
+ """Display available AI providers."""
316
+ try:
317
+ from truthound_dashboard.translate import list_available_providers
318
+ except ImportError:
319
+ console.print(
320
+ "[yellow]Warning:[/yellow] Translation module not available.\n"
321
+ "Install with: pip install truthound-dashboard[translate]"
322
+ )
323
+ raise typer.Exit(1)
324
+
325
+ providers = list_available_providers()
326
+
327
+ table = Table(title="Available AI Providers")
328
+ table.add_column("Provider", style="cyan")
329
+ table.add_column("Environment Variable", style="yellow")
330
+ table.add_column("Default Model", style="green")
331
+ table.add_column("Status", style="bold")
332
+
333
+ for p in providers:
334
+ status = "[green]Ready[/green]" if p["available"] else "[red]Not configured[/red]"
335
+ table.add_row(
336
+ p["display_name"],
337
+ p["env_var"],
338
+ p["default_model"],
339
+ status,
340
+ )
341
+
342
+ console.print(table)
343
+ console.print("\n[dim]Set the environment variable to enable a provider.[/dim]")
344
+
345
+
346
+ def _show_languages() -> None:
347
+ """Display supported languages."""
348
+ try:
349
+ from truthound_dashboard.translate.config_updater import get_supported_languages
350
+ except ImportError:
351
+ console.print(
352
+ "[yellow]Warning:[/yellow] Translation module not available.\n"
353
+ "Install with: pip install truthound-dashboard[translate]"
354
+ )
355
+ raise typer.Exit(1)
356
+
357
+ languages = get_supported_languages()
358
+
359
+ table = Table(title="Supported Languages")
360
+ table.add_column("Code", style="cyan")
361
+ table.add_column("Name", style="green")
362
+ table.add_column("Native Name", style="yellow")
363
+ table.add_column("Flag")
364
+
365
+ for lang in sorted(languages, key=lambda x: x["code"]):
366
+ table.add_row(
367
+ lang["code"],
368
+ lang["name"],
369
+ lang["native_name"],
370
+ lang["flag"],
371
+ )
372
+
373
+ console.print(table)
374
+ console.print("\n[dim]Use comma-separated codes: -l ja,zh,de[/dim]")
375
+
376
+
377
+ def _find_frontend_dir() -> Path | None:
378
+ """Find the frontend directory relative to the package."""
379
+ import truthound_dashboard
380
+
381
+ # Try to find frontend relative to package
382
+ package_dir = Path(truthound_dashboard.__file__).parent
383
+ candidates = [
384
+ package_dir.parent.parent / "frontend", # Development layout
385
+ package_dir / "frontend", # Installed layout
386
+ Path.cwd() / "frontend", # Current directory
387
+ ]
388
+
389
+ for candidate in candidates:
390
+ if candidate.exists() and (candidate / "src").exists():
391
+ return candidate
392
+
393
+ return None
394
+
395
+
396
+ async def _run_translation(
397
+ target_langs: list[str],
398
+ provider_name: str | None,
399
+ model_name: str | None,
400
+ frontend_dir: Path,
401
+ dry_run: bool,
402
+ ) -> None:
403
+ """Run the translation process."""
404
+ try:
405
+ from truthound_dashboard.translate import (
406
+ ContentTranslator,
407
+ IntlayerConfigUpdater,
408
+ ProviderConfig,
409
+ detect_provider,
410
+ get_provider,
411
+ )
412
+ from truthound_dashboard.translate.config_updater import LOCALE_MAPPINGS
413
+ from truthound_dashboard.translate.exceptions import (
414
+ APIKeyNotFoundError,
415
+ ProviderNotFoundError,
416
+ TranslationError,
417
+ )
418
+ except ImportError as e:
419
+ console.print(
420
+ f"[red]Error:[/red] Translation module not available: {e}\n"
421
+ "Install with: pip install truthound-dashboard[translate]"
422
+ )
423
+ raise typer.Exit(1)
424
+
425
+ # Validate languages
426
+ invalid_langs = [lang for lang in target_langs if lang not in LOCALE_MAPPINGS]
427
+ if invalid_langs:
428
+ console.print(
429
+ f"[red]Error:[/red] Unsupported language(s): {', '.join(invalid_langs)}\n"
430
+ "Use --list-languages to see supported languages."
431
+ )
432
+ raise typer.Exit(1)
433
+
434
+ # Get provider
435
+ try:
436
+ config = ProviderConfig(model=model_name) if model_name else None
437
+
438
+ if provider_name:
439
+ provider = get_provider(provider_name, config)
440
+ console.print(f"[green]✓[/green] Using provider: {provider_name}")
441
+ else:
442
+ provider = detect_provider(config)
443
+ console.print(
444
+ f"[green]✓[/green] Auto-detected provider: {provider.name}"
445
+ )
446
+
447
+ console.print(f"[green]✓[/green] Model: {provider.model}")
448
+
449
+ except ProviderNotFoundError as e:
450
+ console.print(f"[red]Error:[/red] {e}")
451
+ console.print("\nUse --list-providers to see available providers.")
452
+ raise typer.Exit(1)
453
+ except APIKeyNotFoundError as e:
454
+ console.print(f"[red]Error:[/red] {e}")
455
+ raise typer.Exit(1)
456
+
457
+ # Show what will be translated
458
+ console.print(f"[green]✓[/green] Frontend directory: {frontend_dir}")
459
+ console.print(f"[green]✓[/green] Target languages: {', '.join(target_langs)}")
460
+
461
+ if dry_run:
462
+ console.print("\n[yellow]Dry run mode - no changes will be made[/yellow]")
463
+ translator = ContentTranslator(provider, frontend_dir)
464
+ content_files = translator.find_content_files()
465
+ console.print(f"\nFound {len(content_files)} content files to translate:")
466
+ for f in content_files:
467
+ console.print(f" - {f.relative_to(frontend_dir)}")
468
+ raise typer.Exit()
469
+
470
+ # Run translation
471
+ console.print("\n[bold]Starting translation...[/bold]\n")
472
+
473
+ translator = ContentTranslator(provider, frontend_dir)
474
+ content_files = translator.find_content_files()
475
+
476
+ with Progress(
477
+ SpinnerColumn(),
478
+ TextColumn("[progress.description]{task.description}"),
479
+ console=console,
480
+ ) as progress:
481
+ task = progress.add_task("Translating...", total=len(content_files))
482
+
483
+ def on_progress(current: int, total: int, filename: str) -> None:
484
+ progress.update(task, completed=current, description=f"Translating {filename}")
485
+
486
+ try:
487
+ results = await translator.translate_all(
488
+ target_langs=target_langs,
489
+ on_progress=on_progress,
490
+ )
491
+ except TranslationError as e:
492
+ console.print(f"\n[red]Translation failed:[/red] {e}")
493
+ raise typer.Exit(1)
494
+
495
+ # Update config files
496
+ console.print("\n[bold]Updating configuration...[/bold]")
497
+ try:
498
+ config_updater = IntlayerConfigUpdater(frontend_dir)
499
+ added_langs = config_updater.add_languages(target_langs)
500
+ if added_langs:
501
+ console.print(
502
+ f"[green]✓[/green] Added languages to config: {', '.join(added_langs)}"
503
+ )
504
+ else:
505
+ console.print("[dim]Languages already in config[/dim]")
506
+ except Exception as e:
507
+ console.print(f"[yellow]Warning:[/yellow] Could not update config: {e}")
508
+
509
+ # Show results
510
+ stats = translator.get_translation_stats(results)
511
+
512
+ console.print(
513
+ Panel(
514
+ f"[bold]Translation Complete[/bold]\n\n"
515
+ f"Files processed: {stats['files_processed']}\n"
516
+ f"Entries translated: {stats['entries_translated']}\n"
517
+ f"Entries skipped: {stats['entries_skipped']}\n"
518
+ f"Errors: {stats['total_errors']}",
519
+ title="Results",
520
+ border_style="green" if stats["total_errors"] == 0 else "yellow",
521
+ )
522
+ )
523
+
524
+ if stats["all_errors"]:
525
+ console.print("\n[yellow]Errors encountered:[/yellow]")
526
+ for error in stats["all_errors"][:10]: # Show first 10 errors
527
+ console.print(f" [red]•[/red] {error}")
528
+ if len(stats["all_errors"]) > 10:
529
+ console.print(f" ... and {len(stats['all_errors']) - 10} more errors")
530
+
531
+ console.print("\n[bold]Next steps:[/bold]")
532
+ console.print(" 1. Review the translated content files")
533
+ console.print(" 2. Rebuild the frontend: cd frontend && npm run build")
534
+ console.print(" 3. Start the server: truthound serve")
535
+
536
+
182
537
  def register_commands(typer_app: typer.Typer) -> None:
183
538
  """Register commands with truthound CLI plugin system.
184
539
 
@@ -221,6 +576,48 @@ def register_commands(typer_app: typer.Typer) -> None:
221
576
  reload=reload,
222
577
  )
223
578
 
579
+ @typer_app.command(name="translate")
580
+ def translate_ui(
581
+ languages: Annotated[
582
+ str | None,
583
+ typer.Option("--languages", "-l", help="Target languages (comma-separated)"),
584
+ ] = None,
585
+ provider: Annotated[
586
+ str | None,
587
+ typer.Option("--provider", "-p", help="AI provider"),
588
+ ] = None,
589
+ model: Annotated[
590
+ str | None,
591
+ typer.Option("--model", "-m", help="Model name"),
592
+ ] = None,
593
+ frontend_dir: Annotated[
594
+ Path | None,
595
+ typer.Option("--frontend-dir", "-f", help="Frontend directory path"),
596
+ ] = None,
597
+ dry_run: Annotated[
598
+ bool,
599
+ typer.Option("--dry-run", help="Show what would be translated"),
600
+ ] = False,
601
+ list_providers: Annotated[
602
+ bool,
603
+ typer.Option("--list-providers", help="List available AI providers"),
604
+ ] = False,
605
+ list_languages: Annotated[
606
+ bool,
607
+ typer.Option("--list-languages", help="List supported languages"),
608
+ ] = False,
609
+ ) -> None:
610
+ """Translate UI to additional languages using AI."""
611
+ translate(
612
+ languages=languages,
613
+ provider=provider,
614
+ model=model,
615
+ frontend_dir=frontend_dir,
616
+ dry_run=dry_run,
617
+ list_providers=list_providers,
618
+ list_languages=list_languages,
619
+ )
620
+
224
621
 
225
622
  if __name__ == "__main__":
226
623
  app()
@@ -142,6 +142,13 @@ from .truthound_adapter import (
142
142
  get_adapter,
143
143
  reset_adapter,
144
144
  )
145
+ # Phase 5 Services
146
+ from .phase5 import (
147
+ ActivityLogger,
148
+ CatalogService,
149
+ CollaborationService,
150
+ GlossaryService,
151
+ )
145
152
 
146
153
  __all__ = [
147
154
  # Base classes
@@ -261,4 +268,9 @@ __all__ = [
261
268
  "StratifiedSamplingStrategy",
262
269
  "get_sampler",
263
270
  "reset_sampler",
271
+ # Phase 5 Services
272
+ "GlossaryService",
273
+ "CatalogService",
274
+ "CollaborationService",
275
+ "ActivityLogger",
264
276
  ]
@@ -0,0 +1,17 @@
1
+ """Phase 5: Business Glossary & Data Catalog services.
2
+
3
+ This module provides services for managing business glossary terms,
4
+ data catalog assets, and collaboration features.
5
+ """
6
+
7
+ from .activity import ActivityLogger
8
+ from .catalog import CatalogService
9
+ from .glossary import GlossaryService
10
+ from .collaboration import CollaborationService
11
+
12
+ __all__ = [
13
+ "GlossaryService",
14
+ "CatalogService",
15
+ "CollaborationService",
16
+ "ActivityLogger",
17
+ ]
@@ -0,0 +1,144 @@
1
+ """Activity logging service for Phase 5.
2
+
3
+ Provides centralized activity logging for all Phase 5 operations.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from sqlalchemy import select
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+ from truthound_dashboard.db import Activity, ActivityAction, BaseRepository, ResourceType
14
+
15
+
16
+ class ActivityRepository(BaseRepository[Activity]):
17
+ """Repository for Activity model operations."""
18
+
19
+ model = Activity
20
+
21
+ async def get_for_resource(
22
+ self,
23
+ resource_type: str,
24
+ resource_id: str,
25
+ *,
26
+ limit: int = 50,
27
+ ) -> list[Activity]:
28
+ """Get activities for a specific resource."""
29
+ result = await self.session.execute(
30
+ select(Activity)
31
+ .where(Activity.resource_type == resource_type)
32
+ .where(Activity.resource_id == resource_id)
33
+ .order_by(Activity.created_at.desc())
34
+ .limit(limit)
35
+ )
36
+ return list(result.scalars().all())
37
+
38
+ async def get_recent(
39
+ self,
40
+ *,
41
+ resource_type: str | None = None,
42
+ limit: int = 50,
43
+ ) -> list[Activity]:
44
+ """Get recent activities."""
45
+ query = select(Activity).order_by(Activity.created_at.desc()).limit(limit)
46
+
47
+ if resource_type:
48
+ query = query.where(Activity.resource_type == resource_type)
49
+
50
+ result = await self.session.execute(query)
51
+ return list(result.scalars().all())
52
+
53
+
54
+ class ActivityLogger:
55
+ """Service for logging activities.
56
+
57
+ Usage:
58
+ logger = ActivityLogger(session)
59
+ await logger.log(ResourceType.TERM, term_id, ActivityAction.CREATED,
60
+ description="Created term: Customer ID")
61
+ """
62
+
63
+ def __init__(self, session: AsyncSession) -> None:
64
+ self.session = session
65
+ self.repository = ActivityRepository(session)
66
+
67
+ async def log(
68
+ self,
69
+ resource_type: ResourceType | str,
70
+ resource_id: str,
71
+ action: ActivityAction | str,
72
+ *,
73
+ actor_id: str | None = None,
74
+ description: str | None = None,
75
+ metadata: dict[str, Any] | None = None,
76
+ ) -> Activity:
77
+ """Log an activity.
78
+
79
+ Args:
80
+ resource_type: Type of resource (term, asset, column, category).
81
+ resource_id: Resource ID.
82
+ action: Action performed (created, updated, deleted, etc).
83
+ actor_id: User who performed the action.
84
+ description: Human-readable description.
85
+ metadata: Additional metadata as JSON.
86
+
87
+ Returns:
88
+ Created activity record.
89
+ """
90
+ resource_type_value = (
91
+ resource_type.value
92
+ if isinstance(resource_type, ResourceType)
93
+ else resource_type
94
+ )
95
+ action_value = (
96
+ action.value if isinstance(action, ActivityAction) else action
97
+ )
98
+
99
+ return await self.repository.create(
100
+ resource_type=resource_type_value,
101
+ resource_id=resource_id,
102
+ action=action_value,
103
+ actor_id=actor_id,
104
+ description=description,
105
+ metadata=metadata,
106
+ )
107
+
108
+ async def get_for_resource(
109
+ self,
110
+ resource_type: ResourceType | str,
111
+ resource_id: str,
112
+ *,
113
+ limit: int = 50,
114
+ ) -> list[Activity]:
115
+ """Get activities for a specific resource."""
116
+ resource_type_value = (
117
+ resource_type.value
118
+ if isinstance(resource_type, ResourceType)
119
+ else resource_type
120
+ )
121
+ return await self.repository.get_for_resource(
122
+ resource_type_value,
123
+ resource_id,
124
+ limit=limit,
125
+ )
126
+
127
+ async def get_recent(
128
+ self,
129
+ *,
130
+ resource_type: ResourceType | str | None = None,
131
+ limit: int = 50,
132
+ ) -> list[Activity]:
133
+ """Get recent activities."""
134
+ resource_type_value = None
135
+ if resource_type:
136
+ resource_type_value = (
137
+ resource_type.value
138
+ if isinstance(resource_type, ResourceType)
139
+ else resource_type
140
+ )
141
+ return await self.repository.get_recent(
142
+ resource_type=resource_type_value,
143
+ limit=limit,
144
+ )