gaard-api 0.2.4__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 (73) hide show
  1. {gaard_api-0.2.4/src/gaard_api.egg-info → gaard_api-0.2.6}/PKG-INFO +5 -5
  2. {gaard_api-0.2.4 → gaard_api-0.2.6}/pyproject.toml +5 -5
  3. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/api/v1/admin.py +98 -0
  4. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/api/v1/dashboards.py +77 -1
  5. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/example_data/medical_poc/seed.sql +206 -50
  6. gaard_api-0.2.6/src/gaard_api/example_database.py +474 -0
  7. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/main.py +1 -1
  8. {gaard_api-0.2.4 → gaard_api-0.2.6/src/gaard_api.egg-info}/PKG-INFO +5 -5
  9. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api.egg-info/requires.txt +4 -4
  10. {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_admin_api.py +109 -3
  11. gaard_api-0.2.6/tests/test_server_cli.py +215 -0
  12. gaard_api-0.2.4/src/gaard_api/example_database.py +0 -135
  13. gaard_api-0.2.4/tests/test_server_cli.py +0 -111
  14. {gaard_api-0.2.4 → gaard_api-0.2.6}/MANIFEST.in +0 -0
  15. {gaard_api-0.2.4 → gaard_api-0.2.6}/README.md +0 -0
  16. {gaard_api-0.2.4 → gaard_api-0.2.6}/setup.cfg +0 -0
  17. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/__init__.py +0 -0
  18. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin/__init__.py +0 -0
  19. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin/database.py +0 -0
  20. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin/defaults.py +0 -0
  21. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin/models.py +0 -0
  22. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin/prompt_runtime.py +0 -0
  23. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin/security.py +0 -0
  24. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin/services.py +0 -0
  25. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin-web/assets/getgaard.svg +0 -0
  26. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin-web/assets/main.js +0 -0
  27. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin-web/assets/styles.css +0 -0
  28. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js +0 -0
  29. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack.min.css +0 -0
  30. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin-web/index.html +0 -0
  31. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin-web/package.json +0 -0
  32. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/api/__init__.py +0 -0
  33. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/api/v1/__init__.py +0 -0
  34. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/api/v1/analysis.py +0 -0
  35. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/api/v1/prompts.py +0 -0
  36. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/api/v1/query.py +0 -0
  37. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/api/v1/schema.py +0 -0
  38. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/api_registry.py +0 -0
  39. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/auth_dependencies.py +0 -0
  40. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/auth_hooks.py +0 -0
  41. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/cli.py +0 -0
  42. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/cli_commands.py +0 -0
  43. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/conversations.py +0 -0
  44. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/core/__init__.py +0 -0
  45. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/core/error_handlers.py +0 -0
  46. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/core/schema_cache.py +0 -0
  47. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/core/settings.py +0 -0
  48. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/example_data/__init__.py +0 -0
  49. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
  50. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
  51. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/extension_services.py +0 -0
  52. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/extensions.py +0 -0
  53. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/license.py +0 -0
  54. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/package_updates.py +0 -0
  55. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/py.typed +0 -0
  56. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/query_hooks.py +0 -0
  57. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/server_cli.py +0 -0
  58. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/siem.py +0 -0
  59. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/tls_http.py +0 -0
  60. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api.egg-info/SOURCES.txt +0 -0
  61. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api.egg-info/dependency_links.txt +0 -0
  62. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api.egg-info/entry_points.txt +0 -0
  63. {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api.egg-info/top_level.txt +0 -0
  64. {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_analysis_api.py +0 -0
  65. {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_api_registry.py +0 -0
  66. {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_conversation_api.py +0 -0
  67. {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_error_handlers.py +0 -0
  68. {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_license_api.py +0 -0
  69. {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_license_service.py +0 -0
  70. {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_prompt_runtime.py +0 -0
  71. {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_query_error_messages.py +0 -0
  72. {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_settings.py +0 -0
  73. {gaard_api-0.2.4 → 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.4
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.4"
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,
@@ -208,36 +208,132 @@ WITH RECURSIVE
208
208
  seq(n) AS (
209
209
  VALUES (1)
210
210
  UNION ALL
211
- SELECT n + 1 FROM seq WHERE n < 420
211
+ SELECT n + 1 FROM seq WHERE n < 560
212
212
  ),
213
- current_bounds(year_start, today, elapsed_days) AS (
213
+ current_bounds(year_start, today, elapsed_days, year_days) AS (
214
214
  SELECT
215
215
  date('now', 'localtime', 'start of year'),
216
216
  date('now', 'localtime'),
217
- CAST(julianday(date('now', 'localtime')) - julianday(date('now', 'localtime', 'start of year')) AS INTEGER)
217
+ CAST(julianday(date('now', 'localtime')) - julianday(date('now', 'localtime', 'start of year')) AS INTEGER),
218
+ CAST(
219
+ julianday(date('now', 'localtime', 'start of year', '+1 year', '-1 day'))
220
+ - julianday(date('now', 'localtime', 'start of year'))
221
+ AS INTEGER
222
+ )
218
223
  ),
219
- appointment_offsets(n, patient_id, doctor_id, day_offset) AS (
224
+ specialty_mix(n, specialization, doctor_start, doctor_count) AS (
220
225
  SELECT
221
226
  seq.n,
222
227
  CASE
223
- WHEN seq.n <= 140 THEN seq.n
224
- WHEN seq.n % 18 = 0 THEN 7
225
- WHEN seq.n % 22 = 0 THEN 31
226
- WHEN seq.n % 27 = 0 THEN 86
227
- ELSE 1 + ((seq.n * 29) % 140)
228
+ WHEN seq.n % 100 < 34 THEN 'cardiology'
229
+ WHEN seq.n % 100 < 61 THEN 'orthopedics'
230
+ WHEN seq.n % 100 < 80 THEN 'dermatology'
231
+ ELSE 'neurology'
228
232
  END,
229
233
  CASE
230
- WHEN seq.n <= 24 THEN seq.n
231
- ELSE 1 + ((seq.n * 7) % 24)
234
+ WHEN seq.n % 100 < 34 THEN 1
235
+ WHEN seq.n % 100 < 61 THEN 13
236
+ WHEN seq.n % 100 < 80 THEN 19
237
+ ELSE 7
232
238
  END,
233
- CASE
234
- WHEN seq.n = 420 THEN current_bounds.elapsed_days
235
- WHEN seq.n <= 112 THEN CAST((seq.n - 1) / 8 AS INTEGER) % (current_bounds.elapsed_days + 1)
236
- ELSE ((seq.n * 37 + CAST(seq.n / 9 AS INTEGER)) % (current_bounds.elapsed_days + 1))
237
- END
239
+ 6
238
240
  FROM seq
241
+ ),
242
+ specialty_ranked AS (
243
+ SELECT
244
+ specialty_mix.n,
245
+ specialty_mix.specialization,
246
+ specialty_mix.doctor_start,
247
+ specialty_mix.doctor_count,
248
+ ROW_NUMBER() OVER (
249
+ PARTITION BY specialty_mix.specialization
250
+ ORDER BY specialty_mix.n
251
+ ) AS specialty_visit_number,
252
+ COUNT(*) OVER (
253
+ PARTITION BY specialty_mix.specialization
254
+ ) AS specialty_visit_total
255
+ FROM specialty_mix
256
+ ),
257
+ seasonal_offsets AS (
258
+ SELECT
259
+ specialty_ranked.n,
260
+ specialty_ranked.specialization,
261
+ specialty_ranked.doctor_start
262
+ + ((specialty_ranked.specialty_visit_number * 5 + specialty_ranked.n) % specialty_ranked.doctor_count)
263
+ AS doctor_id,
264
+ CASE specialty_ranked.specialization
265
+ WHEN 'cardiology' THEN
266
+ current_bounds.year_days - CAST(
267
+ (
268
+ current_bounds.year_days
269
+ * (specialty_ranked.specialty_visit_total - specialty_ranked.specialty_visit_number + 1)
270
+ * (specialty_ranked.specialty_visit_total - specialty_ranked.specialty_visit_number + 1)
271
+ ) / (
272
+ (specialty_ranked.specialty_visit_total + 1)
273
+ * (specialty_ranked.specialty_visit_total + 1)
274
+ )
275
+ AS INTEGER
276
+ ) + ((specialty_ranked.n * 7) % 10)
277
+ WHEN 'orthopedics' THEN
278
+ CASE
279
+ WHEN specialty_ranked.specialty_visit_number % 6 IN (0, 1, 2) THEN
280
+ 150 + ((specialty_ranked.specialty_visit_number * 11 + specialty_ranked.n) % 90)
281
+ WHEN specialty_ranked.specialty_visit_number % 6 IN (3, 4) THEN
282
+ 240 + ((specialty_ranked.specialty_visit_number * 7 + specialty_ranked.n) % 60)
283
+ ELSE
284
+ 5 + ((specialty_ranked.specialty_visit_number * 13 + specialty_ranked.n) % 55)
285
+ END
286
+ WHEN 'dermatology' THEN
287
+ CASE
288
+ WHEN specialty_ranked.specialty_visit_number % 5 IN (0, 1, 2, 3) THEN
289
+ 95 + ((specialty_ranked.specialty_visit_number * 13 + specialty_ranked.n) % 120)
290
+ ELSE
291
+ 220 + ((specialty_ranked.specialty_visit_number * 17 + specialty_ranked.n) % 70)
292
+ END
293
+ ELSE
294
+ CASE
295
+ WHEN specialty_ranked.specialty_visit_number % 4 IN (0, 1) THEN
296
+ 55 + ((specialty_ranked.specialty_visit_number * 13 + specialty_ranked.n) % 70)
297
+ WHEN specialty_ranked.specialty_visit_number % 4 = 2 THEN
298
+ 245 + ((specialty_ranked.specialty_visit_number * 11 + specialty_ranked.n) % 75)
299
+ ELSE
300
+ 130 + ((specialty_ranked.specialty_visit_number * 17 + specialty_ranked.n) % 80)
301
+ END
302
+ END AS raw_day_offset
303
+ FROM specialty_ranked
239
304
  CROSS JOIN current_bounds
240
305
  ),
306
+ bounded_offsets AS (
307
+ SELECT
308
+ seasonal_offsets.n,
309
+ seasonal_offsets.specialization,
310
+ seasonal_offsets.doctor_id,
311
+ CASE
312
+ WHEN seasonal_offsets.raw_day_offset < 0 THEN 0
313
+ WHEN seasonal_offsets.raw_day_offset > current_bounds.year_days THEN current_bounds.year_days
314
+ ELSE seasonal_offsets.raw_day_offset
315
+ END AS day_offset
316
+ FROM seasonal_offsets
317
+ CROSS JOIN current_bounds
318
+ ),
319
+ appointment_offsets(n, patient_id, doctor_id, day_offset, specialization) AS (
320
+ SELECT
321
+ bounded_offsets.n,
322
+ CASE
323
+ WHEN bounded_offsets.specialization = 'cardiology' AND bounded_offsets.n % 6 = 0 THEN 7
324
+ WHEN bounded_offsets.specialization = 'cardiology' AND bounded_offsets.n % 10 = 0 THEN 31
325
+ WHEN bounded_offsets.specialization = 'cardiology' THEN 1 + ((bounded_offsets.n * 17 + bounded_offsets.day_offset) % 90)
326
+ WHEN bounded_offsets.specialization = 'neurology' AND bounded_offsets.n % 8 = 0 THEN 86
327
+ WHEN bounded_offsets.specialization = 'neurology' THEN 25 + ((bounded_offsets.n * 11 + bounded_offsets.day_offset) % 90)
328
+ WHEN bounded_offsets.specialization = 'orthopedics' THEN 1 + ((bounded_offsets.n * 29 + bounded_offsets.day_offset) % 140)
329
+ ELSE 1 + ((bounded_offsets.n * 13 + bounded_offsets.day_offset * 2) % 140)
330
+ END
331
+ AS patient_id,
332
+ bounded_offsets.doctor_id,
333
+ bounded_offsets.day_offset,
334
+ bounded_offsets.specialization
335
+ FROM bounded_offsets
336
+ ),
241
337
  appointment_data AS (
242
338
  SELECT
243
339
  appointment_offsets.n,
@@ -245,46 +341,103 @@ WITH RECURSIVE
245
341
  appointment_offsets.doctor_id,
246
342
  date(current_bounds.year_start, '+' || appointment_offsets.day_offset || ' days') AS appointment_date,
247
343
  printf('%02d:%02d', 8 + ((appointment_offsets.n * 7) % 10), (appointment_offsets.n * 15) % 60) AS appointment_time,
248
- CASE
249
- WHEN appointment_offsets.n % 10 = 0 THEN 60
250
- WHEN appointment_offsets.n % 4 = 0 THEN 45
251
- WHEN appointment_offsets.n % 3 = 0 THEN 15
252
- ELSE 30
344
+ CASE appointment_offsets.specialization
345
+ WHEN 'cardiology' THEN
346
+ CASE
347
+ WHEN appointment_offsets.n % 6 = 0 THEN 60
348
+ WHEN appointment_offsets.n % 2 = 0 THEN 45
349
+ ELSE 30
350
+ END
351
+ WHEN 'neurology' THEN
352
+ CASE
353
+ WHEN appointment_offsets.n % 5 = 0 THEN 60
354
+ WHEN appointment_offsets.n % 3 = 0 THEN 30
355
+ ELSE 45
356
+ END
357
+ WHEN 'orthopedics' THEN
358
+ CASE
359
+ WHEN appointment_offsets.n % 7 = 0 THEN 60
360
+ WHEN appointment_offsets.n % 2 = 0 THEN 30
361
+ ELSE 45
362
+ END
363
+ ELSE
364
+ CASE
365
+ WHEN appointment_offsets.n % 5 = 0 THEN 45
366
+ WHEN appointment_offsets.n % 2 = 0 THEN 15
367
+ ELSE 30
368
+ END
253
369
  END AS duration_minutes,
254
370
  CASE
371
+ WHEN appointment_offsets.day_offset > current_bounds.elapsed_days THEN
372
+ CASE
373
+ WHEN appointment_offsets.n % 31 = 0 THEN 'cancelled'
374
+ ELSE 'scheduled'
375
+ END
255
376
  WHEN appointment_offsets.day_offset = current_bounds.elapsed_days THEN
256
377
  CASE
257
378
  WHEN appointment_offsets.n % 5 = 0 THEN 'cancelled'
258
379
  WHEN appointment_offsets.n % 3 = 0 THEN 'checked_in'
259
380
  ELSE 'scheduled'
260
381
  END
261
- WHEN appointment_offsets.n % 23 = 0 THEN 'no_show'
262
- WHEN appointment_offsets.n % 17 = 0 THEN 'cancelled'
382
+ WHEN appointment_offsets.specialization = 'dermatology' AND appointment_offsets.n % 22 = 0 THEN 'no_show'
383
+ WHEN appointment_offsets.specialization = 'orthopedics' AND appointment_offsets.n % 18 = 0 THEN 'cancelled'
384
+ WHEN appointment_offsets.specialization = 'cardiology' AND appointment_offsets.n % 27 = 0 THEN 'cancelled'
385
+ WHEN appointment_offsets.n % 29 = 0 THEN 'no_show'
386
+ WHEN appointment_offsets.n % 21 = 0 THEN 'cancelled'
263
387
  ELSE 'completed'
264
388
  END AS status,
265
- CASE appointment_offsets.n % 6
266
- WHEN 0 THEN 'telemedicine'
267
- WHEN 1 THEN 'first_visit'
268
- WHEN 2 THEN 'follow_up'
269
- WHEN 3 THEN 'control'
270
- WHEN 4 THEN 'urgent'
271
- ELSE 'preventive'
389
+ CASE appointment_offsets.specialization
390
+ WHEN 'cardiology' THEN
391
+ CASE appointment_offsets.n % 5
392
+ WHEN 0 THEN 'telemedicine'
393
+ WHEN 1 THEN 'follow_up'
394
+ WHEN 2 THEN 'control'
395
+ WHEN 3 THEN 'first_visit'
396
+ ELSE 'preventive'
397
+ END
398
+ WHEN 'neurology' THEN
399
+ CASE appointment_offsets.n % 5
400
+ WHEN 0 THEN 'first_visit'
401
+ WHEN 1 THEN 'follow_up'
402
+ WHEN 2 THEN 'control'
403
+ WHEN 3 THEN 'telemedicine'
404
+ ELSE 'urgent'
405
+ END
406
+ WHEN 'orthopedics' THEN
407
+ CASE appointment_offsets.n % 5
408
+ WHEN 0 THEN 'urgent'
409
+ WHEN 1 THEN 'first_visit'
410
+ WHEN 2 THEN 'follow_up'
411
+ WHEN 3 THEN 'control'
412
+ ELSE 'urgent'
413
+ END
414
+ ELSE
415
+ CASE appointment_offsets.n % 5
416
+ WHEN 0 THEN 'first_visit'
417
+ WHEN 1 THEN 'control'
418
+ WHEN 2 THEN 'preventive'
419
+ WHEN 3 THEN 'follow_up'
420
+ ELSE 'telemedicine'
421
+ END
272
422
  END AS visit_type,
273
423
  CASE
274
- WHEN appointment_offsets.n % 29 = 0 THEN 'high'
424
+ WHEN appointment_offsets.specialization = 'orthopedics' AND appointment_offsets.n % 7 = 0 THEN 'high'
425
+ WHEN appointment_offsets.specialization = 'cardiology' AND appointment_offsets.n % 9 = 0 THEN 'high'
426
+ WHEN appointment_offsets.specialization = 'neurology' AND appointment_offsets.n % 13 = 0 THEN 'high'
275
427
  WHEN appointment_offsets.n % 11 = 0 THEN 'elevated'
276
428
  ELSE 'routine'
277
429
  END AS priority,
278
- CASE
279
- WHEN appointment_offsets.doctor_id BETWEEN 1 AND 6 THEN
280
- CASE appointment_offsets.n % 5
281
- WHEN 0 THEN 'blood pressure follow-up'
282
- WHEN 1 THEN 'chest pain evaluation'
283
- WHEN 2 THEN 'palpitations'
284
- WHEN 3 THEN 'shortness of breath'
430
+ CASE appointment_offsets.specialization
431
+ WHEN 'cardiology' THEN
432
+ CASE
433
+ WHEN appointment_offsets.day_offset > 260 THEN 'winter cardiovascular risk review'
434
+ WHEN appointment_offsets.n % 5 = 0 THEN 'blood pressure follow-up'
435
+ WHEN appointment_offsets.n % 5 = 1 THEN 'chest pain evaluation'
436
+ WHEN appointment_offsets.n % 5 = 2 THEN 'palpitations'
437
+ WHEN appointment_offsets.n % 5 = 3 THEN 'shortness of breath'
285
438
  ELSE 'post-treatment control'
286
439
  END
287
- WHEN appointment_offsets.doctor_id BETWEEN 7 AND 12 THEN
440
+ WHEN 'neurology' THEN
288
441
  CASE appointment_offsets.n % 5
289
442
  WHEN 0 THEN 'migraine follow-up'
290
443
  WHEN 1 THEN 'dizziness'
@@ -292,20 +445,23 @@ WITH RECURSIVE
292
445
  WHEN 3 THEN 'limb numbness'
293
446
  ELSE 'seizure control'
294
447
  END
295
- WHEN appointment_offsets.doctor_id BETWEEN 13 AND 18 THEN
296
- CASE appointment_offsets.n % 5
297
- WHEN 0 THEN 'knee pain'
298
- WHEN 1 THEN 'back pain'
299
- WHEN 2 THEN 'ankle injury'
300
- WHEN 3 THEN 'shoulder mobility issue'
448
+ WHEN 'orthopedics' THEN
449
+ CASE
450
+ WHEN appointment_offsets.day_offset BETWEEN 150 AND 240 THEN 'sports injury'
451
+ WHEN appointment_offsets.day_offset < 70 THEN 'winter fall injury'
452
+ WHEN appointment_offsets.n % 5 = 0 THEN 'knee pain'
453
+ WHEN appointment_offsets.n % 5 = 1 THEN 'back pain'
454
+ WHEN appointment_offsets.n % 5 = 2 THEN 'ankle injury'
455
+ WHEN appointment_offsets.n % 5 = 3 THEN 'shoulder mobility issue'
301
456
  ELSE 'post-injury control'
302
457
  END
303
458
  ELSE
304
- CASE appointment_offsets.n % 5
305
- WHEN 0 THEN 'rash assessment'
306
- WHEN 1 THEN 'mole check'
307
- WHEN 2 THEN 'acne treatment'
308
- WHEN 3 THEN 'psoriasis follow-up'
459
+ CASE
460
+ WHEN appointment_offsets.day_offset BETWEEN 95 AND 220 THEN 'sun exposure skin check'
461
+ WHEN appointment_offsets.n % 5 = 0 THEN 'rash assessment'
462
+ WHEN appointment_offsets.n % 5 = 1 THEN 'mole check'
463
+ WHEN appointment_offsets.n % 5 = 2 THEN 'acne treatment'
464
+ WHEN appointment_offsets.n % 5 = 3 THEN 'psoriasis follow-up'
309
465
  ELSE 'allergy symptoms'
310
466
  END
311
467
  END AS reason