gaard-api 0.2.5__tar.gz → 0.2.6__tar.gz

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 (71) hide show
  1. {gaard_api-0.2.5/src/gaard_api.egg-info → gaard_api-0.2.6}/PKG-INFO +5 -5
  2. {gaard_api-0.2.5 → gaard_api-0.2.6}/pyproject.toml +5 -5
  3. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/api/v1/admin.py +98 -0
  4. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/api/v1/dashboards.py +77 -1
  5. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/main.py +1 -1
  6. {gaard_api-0.2.5 → gaard_api-0.2.6/src/gaard_api.egg-info}/PKG-INFO +5 -5
  7. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api.egg-info/requires.txt +4 -4
  8. {gaard_api-0.2.5 → gaard_api-0.2.6}/tests/test_admin_api.py +99 -0
  9. {gaard_api-0.2.5 → gaard_api-0.2.6}/MANIFEST.in +0 -0
  10. {gaard_api-0.2.5 → gaard_api-0.2.6}/README.md +0 -0
  11. {gaard_api-0.2.5 → gaard_api-0.2.6}/setup.cfg +0 -0
  12. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/__init__.py +0 -0
  13. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/admin/__init__.py +0 -0
  14. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/admin/database.py +0 -0
  15. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/admin/defaults.py +0 -0
  16. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/admin/models.py +0 -0
  17. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/admin/prompt_runtime.py +0 -0
  18. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/admin/security.py +0 -0
  19. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/admin/services.py +0 -0
  20. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/admin-web/assets/getgaard.svg +0 -0
  21. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/admin-web/assets/main.js +0 -0
  22. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/admin-web/assets/styles.css +0 -0
  23. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js +0 -0
  24. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack.min.css +0 -0
  25. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/admin-web/index.html +0 -0
  26. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/admin-web/package.json +0 -0
  27. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/api/__init__.py +0 -0
  28. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/api/v1/__init__.py +0 -0
  29. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/api/v1/analysis.py +0 -0
  30. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/api/v1/prompts.py +0 -0
  31. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/api/v1/query.py +0 -0
  32. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/api/v1/schema.py +0 -0
  33. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/api_registry.py +0 -0
  34. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/auth_dependencies.py +0 -0
  35. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/auth_hooks.py +0 -0
  36. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/cli.py +0 -0
  37. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/cli_commands.py +0 -0
  38. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/conversations.py +0 -0
  39. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/core/__init__.py +0 -0
  40. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/core/error_handlers.py +0 -0
  41. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/core/schema_cache.py +0 -0
  42. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/core/settings.py +0 -0
  43. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/example_data/__init__.py +0 -0
  44. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
  45. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
  46. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
  47. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/example_database.py +0 -0
  48. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/extension_services.py +0 -0
  49. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/extensions.py +0 -0
  50. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/license.py +0 -0
  51. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/package_updates.py +0 -0
  52. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/py.typed +0 -0
  53. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/query_hooks.py +0 -0
  54. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/server_cli.py +0 -0
  55. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/siem.py +0 -0
  56. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api/tls_http.py +0 -0
  57. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api.egg-info/SOURCES.txt +0 -0
  58. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api.egg-info/dependency_links.txt +0 -0
  59. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api.egg-info/entry_points.txt +0 -0
  60. {gaard_api-0.2.5 → gaard_api-0.2.6}/src/gaard_api.egg-info/top_level.txt +0 -0
  61. {gaard_api-0.2.5 → gaard_api-0.2.6}/tests/test_analysis_api.py +0 -0
  62. {gaard_api-0.2.5 → gaard_api-0.2.6}/tests/test_api_registry.py +0 -0
  63. {gaard_api-0.2.5 → gaard_api-0.2.6}/tests/test_conversation_api.py +0 -0
  64. {gaard_api-0.2.5 → gaard_api-0.2.6}/tests/test_error_handlers.py +0 -0
  65. {gaard_api-0.2.5 → gaard_api-0.2.6}/tests/test_license_api.py +0 -0
  66. {gaard_api-0.2.5 → gaard_api-0.2.6}/tests/test_license_service.py +0 -0
  67. {gaard_api-0.2.5 → gaard_api-0.2.6}/tests/test_prompt_runtime.py +0 -0
  68. {gaard_api-0.2.5 → gaard_api-0.2.6}/tests/test_query_error_messages.py +0 -0
  69. {gaard_api-0.2.5 → gaard_api-0.2.6}/tests/test_server_cli.py +0 -0
  70. {gaard_api-0.2.5 → gaard_api-0.2.6}/tests/test_settings.py +0 -0
  71. {gaard_api-0.2.5 → gaard_api-0.2.6}/tests/test_tls_http.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-api
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: GAARD backend web services providing admin interface
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
- Requires-Dist: gaard-core==0.2.4
8
- Requires-Dist: gaard-connectors==0.2.4
9
- Requires-Dist: gaard-llm==0.2.4
10
- Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.4
7
+ Requires-Dist: gaard-core==0.2.6
8
+ Requires-Dist: gaard-connectors==0.2.6
9
+ Requires-Dist: gaard-llm==0.2.6
10
+ Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.6
11
11
  Requires-Dist: fastapi>=0.111.0
12
12
  Requires-Dist: uvicorn[standard]>=0.30.0
13
13
  Requires-Dist: pydantic>=2.7.0
@@ -4,16 +4,16 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gaard-api"
7
- version = "0.2.5"
7
+ version = "0.2.6"
8
8
  description = "GAARD backend web services providing admin interface"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
 
12
12
  dependencies = [
13
- "gaard-core==0.2.4",
14
- "gaard-connectors==0.2.4",
15
- "gaard-llm==0.2.4",
16
- "gaard-plugin-api>=0.2.4,<0.3.0",
13
+ "gaard-core==0.2.6",
14
+ "gaard-connectors==0.2.6",
15
+ "gaard-llm==0.2.6",
16
+ "gaard-plugin-api>=0.2.6,<0.3.0",
17
17
  "fastapi>=0.111.0",
18
18
  "uvicorn[standard]>=0.30.0",
19
19
  "pydantic>=2.7.0",
@@ -13,6 +13,7 @@ from gaard_core.errors import (
13
13
  QueryExecutionError,
14
14
  SqlValidationError,
15
15
  )
16
+ from gaard_core.llm_output import remove_thinking_blocks
16
17
  from gaard_core.query_pipeline.models import (
17
18
  OutputClassification,
18
19
  QueryRequest,
@@ -21,6 +22,8 @@ from gaard_core.query_pipeline.models import (
21
22
  )
22
23
  from gaard_core.sql_validator.select_only import SelectOnlySqlValidator
23
24
  from gaard_connectors import ConnectorRegistryError
25
+ from gaard_llm.openai_compatible.client import OpenAICompatibleClient
26
+ from gaard_llm.providers.models import ChatCompletionRequest, ChatMessage
24
27
  from gaard_plugin_api import ExtensionRecord
25
28
  from pydantic import BaseModel, Field
26
29
  from sqlalchemy import select
@@ -251,6 +254,11 @@ class OverviewWidgetFromQueryRequest(BaseModel):
251
254
  )
252
255
 
253
256
 
257
+ class OverviewWidgetTitleSuggestionRequest(BaseModel):
258
+ question: str = Field(min_length=1)
259
+ sql: str | None = None
260
+
261
+
254
262
  class DatasourceConnectorRequest(BaseModel):
255
263
  connector_key: str = Field(min_length=1, pattern=r"^[a-zA-Z0-9_-]+$")
256
264
  name: str = Field(min_length=1)
@@ -503,6 +511,79 @@ def build_client_widget_key(session: Session, label: str, question: str) -> str:
503
511
  return widget_key
504
512
 
505
513
 
514
+ def normalize_metric_title(value: str, fallback: str) -> str:
515
+ cleaned = remove_thinking_blocks(value).strip()
516
+
517
+ if cleaned.startswith("```"):
518
+ cleaned = cleaned.removeprefix("```").strip()
519
+ if cleaned.startswith("text"):
520
+ cleaned = cleaned.removeprefix("text").strip()
521
+ if cleaned.endswith("```"):
522
+ cleaned = cleaned.removesuffix("```").strip()
523
+
524
+ cleaned = cleaned.strip().strip('"').strip("'")
525
+ cleaned = re.sub(r"\s+", " ", cleaned)
526
+ cleaned = cleaned.rstrip(".:;-")
527
+ if not cleaned:
528
+ cleaned = fallback.strip()
529
+
530
+ return cleaned[:80].strip() or "Saved Metric"
531
+
532
+
533
+ def fallback_metric_title(question: str) -> str:
534
+ normalized = re.sub(r"\s+", " ", question.strip())
535
+ normalized = normalized.rstrip("?!.:;-")
536
+ if not normalized:
537
+ return "Saved Metric"
538
+
539
+ return normalized[0].upper() + normalized[1:80]
540
+
541
+
542
+ def suggest_metric_title_with_llm(session: Session, request: OverviewWidgetTitleSuggestionRequest) -> str:
543
+ llm_config = get_llm_runtime_config(session)
544
+ if llm_config.provider != "openai-compatible":
545
+ raise ConfigurationError(f"Unsupported GAARD_LLM_PROVIDER: {llm_config.provider}")
546
+ if not llm_config.api_key or llm_config.api_key == "change-me":
547
+ raise ConfigurationError("GAARD_LLM_API_KEY must be configured to suggest metric titles.")
548
+
549
+ client = OpenAICompatibleClient(
550
+ base_url=llm_config.base_url,
551
+ api_key=llm_config.api_key,
552
+ timeout_seconds=llm_config.timeout_seconds,
553
+ )
554
+ fallback = fallback_metric_title(request.question)
555
+ response = client.create_chat_completion(
556
+ ChatCompletionRequest(
557
+ model=llm_config.model,
558
+ temperature=0.0,
559
+ extra_body=llm_config.extra_body,
560
+ messages=[
561
+ ChatMessage(
562
+ role="system",
563
+ content=(
564
+ "You write short human-friendly metric names for dashboard widgets. "
565
+ "Return only the metric name, without quotes, markdown, explanations, "
566
+ "or trailing punctuation. Use the same language as the user question. "
567
+ "Prefer 3 to 8 words and keep it under 80 characters."
568
+ ),
569
+ ),
570
+ ChatMessage(
571
+ role="user",
572
+ content=(
573
+ "User question:\n"
574
+ f"{request.question.strip()}\n\n"
575
+ "Generated SQL, if useful:\n"
576
+ f"{(request.sql or '').strip()}\n\n"
577
+ "Return the metric name only."
578
+ ),
579
+ ),
580
+ ],
581
+ )
582
+ )
583
+
584
+ return normalize_metric_title(response.content, fallback)
585
+
586
+
506
587
  def build_client_excel_datasource_key(session: Session, filename: str) -> str:
507
588
  stem = Path(filename).stem or "excel"
508
589
  normalized = re.sub(r"[^a-z0-9_-]+", "_", stem.lower()).strip("_-") or "excel"
@@ -1416,6 +1497,23 @@ def create_overview_widget_from_query(
1416
1497
  }
1417
1498
 
1418
1499
 
1500
+ @router.post("/overview/widgets/title-suggestion")
1501
+ def suggest_overview_widget_title(
1502
+ request: OverviewWidgetTitleSuggestionRequest,
1503
+ _principal: AuthenticatedSession = Depends(get_current_api_user),
1504
+ session: Session = Depends(get_session),
1505
+ ) -> dict[str, Any]:
1506
+ try:
1507
+ title = suggest_metric_title_with_llm(session, request)
1508
+ except (ConfigurationError, LlmProviderError, ValueError) as exc:
1509
+ raise HTTPException(
1510
+ status_code=status.HTTP_400_BAD_REQUEST,
1511
+ detail=str(exc),
1512
+ ) from exc
1513
+
1514
+ return {"title": title}
1515
+
1516
+
1419
1517
  @router.put("/overview/widgets/{widget_key}")
1420
1518
  def update_overview_widget(
1421
1519
  widget_key: str,
@@ -48,6 +48,11 @@ class DashboardCreateRequest(BaseModel):
48
48
  description: str = Field(default="", max_length=2_000)
49
49
 
50
50
 
51
+ class DashboardUpdateRequest(BaseModel):
52
+ name: str = Field(min_length=1, max_length=255)
53
+ description: str = Field(default="", max_length=2_000)
54
+
55
+
51
56
  class ActiveDashboardRequest(BaseModel):
52
57
  dashboard_id: str = Field(min_length=1, max_length=64)
53
58
 
@@ -58,6 +63,10 @@ class DashboardWidgetCreateRequest(BaseModel):
58
63
  visualization_type: str = Field(pattern=r"^(number|bar|stacked_bar|line|multi_line|pie|area|table)$")
59
64
 
60
65
 
66
+ class SavedMetricUpdateRequest(BaseModel):
67
+ label: str = Field(min_length=1, max_length=255)
68
+
69
+
61
70
  class DashboardWidgetLayoutItem(BaseModel):
62
71
  widget_id: str = Field(min_length=1, max_length=64)
63
72
  x: int = Field(ge=0, le=11)
@@ -308,16 +317,49 @@ def list_dashboards(
308
317
 
309
318
  @router.get("/dashboards/metrics")
310
319
  def list_saved_dashboard_metrics(
320
+ include_result: bool = True,
311
321
  principal: AuthenticatedSession = Depends(get_current_api_user),
312
322
  session: Session = Depends(get_session),
313
323
  ) -> dict[str, Any]:
314
324
  metrics = list_saved_metrics_for_owner(session, dashboard_owner_user_id(principal))
315
325
  return {
316
- "items": [serialize_saved_metric(session, metric) for metric in metrics],
326
+ "items": [
327
+ serialize_saved_metric(session, metric, include_result=include_result)
328
+ for metric in metrics
329
+ ],
317
330
  "viewer": dashboard_owner_username(principal),
318
331
  }
319
332
 
320
333
 
334
+ @router.patch("/dashboards/metrics/{widget_key}")
335
+ def update_saved_dashboard_metric(
336
+ widget_key: str,
337
+ request: SavedMetricUpdateRequest,
338
+ principal: AuthenticatedSession = Depends(get_current_api_user),
339
+ session: Session = Depends(get_session),
340
+ ) -> dict[str, Any]:
341
+ owner_user_id = dashboard_owner_user_id(principal)
342
+ metric = get_saved_metric_for_owner(session, widget_key, owner_user_id)
343
+ if metric is None:
344
+ raise HTTPException(
345
+ status_code=status.HTTP_404_NOT_FOUND,
346
+ detail="Saved metric not found.",
347
+ )
348
+
349
+ label = request.label.strip()
350
+ if not label:
351
+ raise HTTPException(
352
+ status_code=status.HTTP_400_BAD_REQUEST,
353
+ detail="Metric name is required.",
354
+ )
355
+
356
+ metric.label = label
357
+ metric.updated_by = dashboard_owner_username(principal)
358
+ session.commit()
359
+
360
+ return {"item": serialize_saved_metric(session, metric, include_result=False)}
361
+
362
+
321
363
  @router.post("/dashboards")
322
364
  def create_dashboard(
323
365
  request: DashboardCreateRequest,
@@ -378,6 +420,40 @@ def set_active_dashboard(
378
420
  }
379
421
 
380
422
 
423
+ @router.put("/dashboards/{dashboard_id}")
424
+ def update_dashboard(
425
+ dashboard_id: str,
426
+ request: DashboardUpdateRequest,
427
+ principal: AuthenticatedSession = Depends(get_current_api_user),
428
+ session: Session = Depends(get_session),
429
+ ) -> dict[str, Any]:
430
+ dashboard = get_dashboard_for_owner(
431
+ session,
432
+ dashboard_id,
433
+ dashboard_owner_user_id(principal),
434
+ )
435
+
436
+ if dashboard is None:
437
+ raise HTTPException(
438
+ status_code=status.HTTP_404_NOT_FOUND,
439
+ detail="Dashboard not found.",
440
+ )
441
+
442
+ name = request.name.strip()
443
+ if not name:
444
+ raise HTTPException(
445
+ status_code=status.HTTP_400_BAD_REQUEST,
446
+ detail="Dashboard name is required.",
447
+ )
448
+
449
+ dashboard.name = name
450
+ dashboard.description = request.description.strip()
451
+ dashboard.owner_username = dashboard_owner_username(principal)
452
+ session.commit()
453
+
454
+ return {"item": serialize_dashboard(dashboard)}
455
+
456
+
381
457
  @router.get("/dashboards/{dashboard_id}/widgets")
382
458
  def list_widgets_for_dashboard(
383
459
  dashboard_id: str,
@@ -29,7 +29,7 @@ async def lifespan(app: FastAPI):
29
29
 
30
30
  app = FastAPI(
31
31
  title="GAARD API",
32
- version="0.2.5",
32
+ version="0.2.6",
33
33
  description="Self-hosted AI SQL Gateway for governed natural-language access to relational data.",
34
34
  lifespan=lifespan,
35
35
  )
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-api
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: GAARD backend web services providing admin interface
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
- Requires-Dist: gaard-core==0.2.4
8
- Requires-Dist: gaard-connectors==0.2.4
9
- Requires-Dist: gaard-llm==0.2.4
10
- Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.4
7
+ Requires-Dist: gaard-core==0.2.6
8
+ Requires-Dist: gaard-connectors==0.2.6
9
+ Requires-Dist: gaard-llm==0.2.6
10
+ Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.6
11
11
  Requires-Dist: fastapi>=0.111.0
12
12
  Requires-Dist: uvicorn[standard]>=0.30.0
13
13
  Requires-Dist: pydantic>=2.7.0
@@ -1,7 +1,7 @@
1
- gaard-core==0.2.4
2
- gaard-connectors==0.2.4
3
- gaard-llm==0.2.4
4
- gaard-plugin-api<0.3.0,>=0.2.4
1
+ gaard-core==0.2.6
2
+ gaard-connectors==0.2.6
3
+ gaard-llm==0.2.6
4
+ gaard-plugin-api<0.3.0,>=0.2.6
5
5
  fastapi>=0.111.0
6
6
  uvicorn[standard]>=0.30.0
7
7
  pydantic>=2.7.0
@@ -400,6 +400,30 @@ def test_dashboards_are_scoped_to_authenticated_user(admin_client: TestClient) -
400
400
  assert metrics_response.json()["items"][0]["widget_key"] == "admin_saved_metric"
401
401
  assert metrics_response.json()["items"][0]["result"]["status"] == "ok"
402
402
 
403
+ lightweight_metrics_response = admin_client.get(
404
+ "/api/v1/dashboards/metrics?include_result=false",
405
+ headers=admin_headers,
406
+ )
407
+ assert lightweight_metrics_response.status_code == 200
408
+ assert lightweight_metrics_response.json()["items"][0]["widget_key"] == "admin_saved_metric"
409
+ assert "result" not in lightweight_metrics_response.json()["items"][0]
410
+
411
+ rename_metric_response = admin_client.patch(
412
+ "/api/v1/dashboards/metrics/admin_saved_metric",
413
+ headers=admin_headers,
414
+ json={"label": "Updated prompt count"},
415
+ )
416
+ assert rename_metric_response.status_code == 200
417
+ assert rename_metric_response.json()["item"]["label"] == "Updated prompt count"
418
+ assert "result" not in rename_metric_response.json()["item"]
419
+
420
+ cross_rename_metric_response = admin_client.patch(
421
+ "/api/v1/dashboards/metrics/admin_saved_metric",
422
+ headers=analyst_headers,
423
+ json={"label": "Cross user rename"},
424
+ )
425
+ assert cross_rename_metric_response.status_code == 404
426
+
403
427
  dashboard_widget_response = admin_client.post(
404
428
  f"/api/v1/dashboards/{admin_dashboard['id']}/widgets",
405
429
  headers=admin_headers,
@@ -483,6 +507,30 @@ def test_dashboards_are_scoped_to_authenticated_user(admin_client: TestClient) -
483
507
  )
484
508
  assert cross_delete.status_code == 404
485
509
 
510
+ update_dashboard_response = admin_client.put(
511
+ f"/api/v1/dashboards/{admin_dashboard['id']}",
512
+ headers=admin_headers,
513
+ json={
514
+ "name": "Operations renamed",
515
+ "description": "Updated daily operational dashboard.",
516
+ },
517
+ )
518
+ assert update_dashboard_response.status_code == 200
519
+ assert update_dashboard_response.json()["item"]["name"] == "Operations renamed"
520
+ assert update_dashboard_response.json()["item"]["description"] == (
521
+ "Updated daily operational dashboard."
522
+ )
523
+
524
+ cross_update_dashboard_response = admin_client.put(
525
+ f"/api/v1/dashboards/{analyst_dashboard['id']}",
526
+ headers=admin_headers,
527
+ json={
528
+ "name": "Cross user rename",
529
+ "description": "Should not be allowed.",
530
+ },
531
+ )
532
+ assert cross_update_dashboard_response.status_code == 404
533
+
486
534
  admin_dashboards = admin_client.get(
487
535
  "/api/v1/admin/dashboards",
488
536
  headers=admin_headers,
@@ -1432,6 +1480,57 @@ def test_overview_widget_can_be_saved_from_query_and_deleted(
1432
1480
  )
1433
1481
 
1434
1482
 
1483
+ def test_overview_widget_title_suggestion_uses_llm(
1484
+ admin_client: TestClient,
1485
+ monkeypatch: pytest.MonkeyPatch,
1486
+ ) -> None:
1487
+ headers = auth_headers(admin_client)
1488
+
1489
+ with create_session() as session:
1490
+ set_setting(session, "gaard_llm_api_key", "test-key", "test")
1491
+ set_setting(session, "gaard_llm_model", "title-model", "test")
1492
+ session.commit()
1493
+
1494
+ class FakeOpenAICompatibleClient:
1495
+ init_kwargs: dict | None = None
1496
+ requests = []
1497
+
1498
+ def __init__(self, **kwargs) -> None:
1499
+ self.__class__.init_kwargs = kwargs
1500
+
1501
+ def create_chat_completion(self, request):
1502
+ self.__class__.requests.append(request)
1503
+ return ChatCompletionResponse(
1504
+ content="```text\nDoctors by Specialty.\n```",
1505
+ model=request.model,
1506
+ )
1507
+
1508
+ monkeypatch.setattr(
1509
+ "gaard_api.api.v1.admin.OpenAICompatibleClient",
1510
+ FakeOpenAICompatibleClient,
1511
+ )
1512
+
1513
+ response = admin_client.post(
1514
+ "/api/v1/admin/overview/widgets/title-suggestion",
1515
+ headers=headers,
1516
+ json={
1517
+ "question": "How many doctors are there by specialty?",
1518
+ "sql": "SELECT specialization, COUNT(*) FROM doctors GROUP BY specialization",
1519
+ },
1520
+ )
1521
+
1522
+ assert response.status_code == 200
1523
+ assert response.json() == {"title": "Doctors by Specialty"}
1524
+ assert FakeOpenAICompatibleClient.init_kwargs is not None
1525
+ assert FakeOpenAICompatibleClient.init_kwargs["api_key"] == "test-key"
1526
+ request = FakeOpenAICompatibleClient.requests[0]
1527
+ assert request.model == "title-model"
1528
+ assert request.temperature == 0.0
1529
+ assert request.messages[0].role == "system"
1530
+ assert "same language as the user question" in request.messages[0].content
1531
+ assert "How many doctors" in request.messages[1].content
1532
+
1533
+
1435
1534
  def test_overview_widget_from_query_strips_datasource_qualifier(
1436
1535
  admin_client: TestClient,
1437
1536
  ) -> None:
File without changes
File without changes
File without changes