cellarbrain 0.2.0__tar.gz → 0.2.2__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 (133) hide show
  1. {cellarbrain-0.2.0/src/cellarbrain.egg-info → cellarbrain-0.2.2}/PKG-INFO +11 -3
  2. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/README.md +10 -2
  3. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/pyproject.toml +7 -1
  4. cellarbrain-0.2.2/src/cellarbrain/__init__.py +7 -0
  5. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/cli.py +26 -4
  6. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/dashboard/app.py +51 -0
  7. cellarbrain-0.2.2/src/cellarbrain/dashboard/static/dashboard.js +101 -0
  8. cellarbrain-0.2.2/src/cellarbrain/dashboard/static/workbench.js +25 -0
  9. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/base.html +162 -0
  10. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/bottles.html +68 -0
  11. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/cellar.html +126 -0
  12. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/drinking.html +53 -0
  13. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/error.html +12 -0
  14. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/errors.html +67 -0
  15. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/index.html +90 -0
  16. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/latency.html +68 -0
  17. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/live.html +102 -0
  18. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/pairing.html +84 -0
  19. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/bottle_rows.html +13 -0
  20. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/dossier_section.html +8 -0
  21. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/error_detail.html +17 -0
  22. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/error_rows.html +18 -0
  23. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/event_stream.html +22 -0
  24. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/pairing_results.html +42 -0
  25. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/session_detail.html +25 -0
  26. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/sql_results.html +34 -0
  27. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/stats_content.html +46 -0
  28. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/tool_rows.html +12 -0
  29. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/turn_events.html +18 -0
  30. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/wine_rows.html +15 -0
  31. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/workbench_response.html +30 -0
  32. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/sessions.html +40 -0
  33. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/sql.html +58 -0
  34. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/stats.html +61 -0
  35. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/tools.html +25 -0
  36. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/tracked.html +44 -0
  37. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/tracked_detail.html +120 -0
  38. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/wine_detail.html +141 -0
  39. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/workbench_batch.html +98 -0
  40. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/workbench_list.html +40 -0
  41. cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/workbench_tool.html +72 -0
  42. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/dashboard/workbench.py +1 -0
  43. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/doctor.py +1 -1
  44. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/mcp_server.py +139 -0
  45. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/observability.py +42 -1
  46. cellarbrain-0.2.2/src/cellarbrain/pairing.py +884 -0
  47. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/transform.py +1 -1
  48. {cellarbrain-0.2.0 → cellarbrain-0.2.2/src/cellarbrain.egg-info}/PKG-INFO +11 -3
  49. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain.egg-info/SOURCES.txt +38 -0
  50. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_cli.py +25 -0
  51. cellarbrain-0.2.2/tests/test_dashboard_pairing.py +198 -0
  52. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_doctor.py +2 -2
  53. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_mcp_server.py +69 -0
  54. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_observability.py +34 -0
  55. cellarbrain-0.2.2/tests/test_pairing.py +687 -0
  56. cellarbrain-0.2.0/src/cellarbrain/__init__.py +0 -1
  57. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/LICENSE +0 -0
  58. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/setup.cfg +0 -0
  59. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/__main__.py +0 -0
  60. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/_query_base.py +0 -0
  61. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/backup.py +0 -0
  62. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/companion_markdown.py +0 -0
  63. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/computed.py +0 -0
  64. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/dashboard/__init__.py +0 -0
  65. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/dashboard/cellar_queries.py +0 -0
  66. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/dashboard/dossier_render.py +0 -0
  67. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/dashboard/queries.py +0 -0
  68. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/dossier_ops.py +0 -0
  69. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/email_poll/__init__.py +0 -0
  70. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/email_poll/credentials.py +0 -0
  71. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/email_poll/etl_runner.py +0 -0
  72. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/email_poll/grouping.py +0 -0
  73. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/email_poll/imap.py +0 -0
  74. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/email_poll/placement.py +0 -0
  75. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/flat.py +0 -0
  76. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/incremental.py +0 -0
  77. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/log.py +0 -0
  78. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/markdown.py +0 -0
  79. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/parsers.py +0 -0
  80. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/price.py +0 -0
  81. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/query.py +0 -0
  82. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/search.py +0 -0
  83. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/settings.py +0 -0
  84. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/slugify.py +0 -0
  85. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/sommelier/__init__.py +0 -0
  86. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/sommelier/catalogue.py +0 -0
  87. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/sommelier/engine.py +0 -0
  88. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/sommelier/index.py +0 -0
  89. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/sommelier/model.py +0 -0
  90. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/sommelier/schemas.py +0 -0
  91. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/sommelier/text_builder.py +0 -0
  92. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/sommelier/training.py +0 -0
  93. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/validate.py +0 -0
  94. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/vinocell_parsers.py +0 -0
  95. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/vinocell_reader.py +0 -0
  96. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/writer.py +0 -0
  97. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain.egg-info/dependency_links.txt +0 -0
  98. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain.egg-info/entry_points.txt +0 -0
  99. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain.egg-info/requires.txt +0 -0
  100. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain.egg-info/top_level.txt +0 -0
  101. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_backup.py +0 -0
  102. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_catalogue.py +0 -0
  103. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_companion_markdown.py +0 -0
  104. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_computed.py +0 -0
  105. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_dashboard_app.py +0 -0
  106. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_dashboard_cellar.py +0 -0
  107. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_dashboard_dossier.py +0 -0
  108. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_dashboard_queries.py +0 -0
  109. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_dashboard_workbench.py +0 -0
  110. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_dataset_factory.py +0 -0
  111. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_dossier_ops.py +0 -0
  112. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_email_poll.py +0 -0
  113. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_flat.py +0 -0
  114. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_incremental.py +0 -0
  115. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_integration.py +0 -0
  116. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_log.py +0 -0
  117. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_markdown.py +0 -0
  118. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_parsers.py +0 -0
  119. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_price.py +0 -0
  120. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_query.py +0 -0
  121. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_reader.py +0 -0
  122. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_search.py +0 -0
  123. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_settings.py +0 -0
  124. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_sommelier.py +0 -0
  125. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_sommelier_data.py +0 -0
  126. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_sommelier_mcp.py +0 -0
  127. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_sommelier_quality.py +0 -0
  128. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_sommelier_training.py +0 -0
  129. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_transform.py +0 -0
  130. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_validate.py +0 -0
  131. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_vinocell_parsers.py +0 -0
  132. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_vinocell_reader.py +0 -0
  133. {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_writer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cellarbrain
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: AI sommelier for your wine cellar — ETL pipeline, DuckDB query layer, Markdown dossiers, and MCP server for wine cellar CSV exports
5
5
  Author-email: Urban Busslinger <urbanb@me.com>
6
6
  License-Expression: MIT
@@ -324,9 +324,17 @@ src/cellarbrain/
324
324
 
325
325
  ## Development
326
326
 
327
+ > **Note:** Unit tests and smoke tests require a source checkout of the
328
+ > repository. They are not included in the PyPI package.
329
+
327
330
  ```bash
328
- # Install with test + research dependencies
329
- pip install -e ".[research]"
331
+ # Clone and set up for development
332
+ git clone https://github.com/urban-buss/cellarbrain.git
333
+ cd cellarbrain
334
+ python -m venv .venv
335
+ .venv\Scripts\activate # Windows
336
+ source .venv/bin/activate # macOS / Linux
337
+ pip install -e ".[dev,research]"
330
338
 
331
339
  # Run tests
332
340
  pytest tests/ -v
@@ -281,9 +281,17 @@ src/cellarbrain/
281
281
 
282
282
  ## Development
283
283
 
284
+ > **Note:** Unit tests and smoke tests require a source checkout of the
285
+ > repository. They are not included in the PyPI package.
286
+
284
287
  ```bash
285
- # Install with test + research dependencies
286
- pip install -e ".[research]"
288
+ # Clone and set up for development
289
+ git clone https://github.com/urban-buss/cellarbrain.git
290
+ cd cellarbrain
291
+ python -m venv .venv
292
+ .venv\Scripts\activate # Windows
293
+ source .venv/bin/activate # macOS / Linux
294
+ pip install -e ".[dev,research]"
287
295
 
288
296
  # Run tests
289
297
  pytest tests/ -v
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cellarbrain"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "AI sommelier for your wine cellar — ETL pipeline, DuckDB query layer, Markdown dossiers, and MCP server for wine cellar CSV exports"
9
9
  requires-python = ">=3.11"
10
10
  license = "MIT"
@@ -65,6 +65,12 @@ cellarbrain = "cellarbrain.cli:main"
65
65
  [tool.setuptools.packages.find]
66
66
  where = ["src"]
67
67
 
68
+ [tool.setuptools.package-data]
69
+ "cellarbrain.dashboard" = [
70
+ "static/**/*",
71
+ "templates/**/*",
72
+ ]
73
+
68
74
  [tool.pytest.ini_options]
69
75
  testpaths = ["tests"]
70
76
 
@@ -0,0 +1,7 @@
1
+ """Cellarbrain — AI sommelier for your wine cellar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import version as _pkg_version
6
+
7
+ __version__: str = _pkg_version("cellarbrain")
@@ -9,6 +9,7 @@ import re
9
9
  import sys
10
10
  import warnings
11
11
  from datetime import UTC, datetime
12
+ from importlib.metadata import version as _pkg_version
12
13
 
13
14
  from . import companion_markdown, incremental, markdown, transform, vinocell_reader, writer
14
15
  from . import validate as val
@@ -472,6 +473,12 @@ def _subcommand_main(argv: list[str]) -> None:
472
473
  prog="cellarbrain",
473
474
  description="Cellarbrain wine cellar toolkit — ETL, query, and agent interface.",
474
475
  )
476
+ parser.add_argument(
477
+ "-V",
478
+ "--version",
479
+ action="version",
480
+ version=f"%(prog)s {_pkg_version('cellarbrain')}",
481
+ )
475
482
  parser.add_argument(
476
483
  "-c",
477
484
  "--config",
@@ -645,6 +652,7 @@ def _subcommand_main(argv: list[str]) -> None:
645
652
  # --- doctor ---
646
653
  doc_parser = sub.add_parser("doctor", help="Run diagnostic health checks on the data directory")
647
654
  doc_parser.add_argument("--json", action="store_true", help="Output results as JSON (for scripting)")
655
+ doc_parser.add_argument("--strict", action="store_true", help="Treat warnings as errors (exit 1)")
648
656
  doc_parser.add_argument(
649
657
  "--check",
650
658
  type=str,
@@ -800,6 +808,7 @@ def _cmd_mcp(args: argparse.Namespace, settings: Settings) -> None:
800
808
  from .mcp_server import mcp, warm_sommelier
801
809
 
802
810
  warm_sommelier()
811
+ mcp.settings.port = args.port
803
812
  mcp.run(transport=args.transport)
804
813
 
805
814
 
@@ -1212,9 +1221,15 @@ def _cmd_dashboard(args: argparse.Namespace, settings: Settings) -> None:
1212
1221
  db_path = str(pathlib.Path(settings.paths.data_dir) / "logs" / "cellarbrain-logs.duckdb")
1213
1222
 
1214
1223
  if not pathlib.Path(db_path).exists():
1215
- print(f"Log store not found: {db_path}", file=sys.stderr)
1216
- print("Run the MCP server first to generate log data.", file=sys.stderr)
1217
- sys.exit(1)
1224
+ # Create an empty log store so the dashboard can start without prior MCP usage
1225
+ pathlib.Path(db_path).parent.mkdir(parents=True, exist_ok=True)
1226
+ import duckdb
1227
+
1228
+ _con = duckdb.connect(db_path)
1229
+ from .observability import _CREATE_TABLE_SQL
1230
+
1231
+ _con.execute(_CREATE_TABLE_SQL)
1232
+ _con.close()
1218
1233
 
1219
1234
  data_dir = settings.paths.data_dir
1220
1235
  app = create_app(
@@ -1251,7 +1266,14 @@ def _cmd_doctor(args: argparse.Namespace, settings: Settings) -> None:
1251
1266
  print(json.dumps(data, indent=2))
1252
1267
  else:
1253
1268
  print(report.summary())
1254
- sys.exit(0 if report.ok else 1)
1269
+
1270
+ from .doctor import Severity
1271
+
1272
+ if args.strict:
1273
+ is_ok = report.worst_severity in (Severity.OK, Severity.INFO)
1274
+ else:
1275
+ is_ok = report.ok
1276
+ sys.exit(0 if is_ok else 1)
1255
1277
 
1256
1278
 
1257
1279
  def _cmd_backup(args: argparse.Namespace, settings: Settings) -> None:
@@ -471,6 +471,56 @@ async def drinking(request: Request) -> HTMLResponse:
471
471
  )
472
472
 
473
473
 
474
+ # ---- Food pairing ---------------------------------------------------------
475
+
476
+
477
+ async def pairing_page(request: Request) -> HTMLResponse:
478
+ """Food pairing interactive page — classify dish, retrieve candidates."""
479
+ con = request.app.state.cellar_con
480
+ if con is None:
481
+ return _TEMPLATES.TemplateResponse(
482
+ request,
483
+ "error.html",
484
+ context={"message": "Cellar data not available."},
485
+ status_code=503,
486
+ )
487
+
488
+ results = None
489
+ params: dict[str, str | None] = {}
490
+
491
+ if request.method == "POST":
492
+ form = await request.form()
493
+ params = {
494
+ "dish_description": form.get("dish_description", ""),
495
+ "category": form.get("category") or None,
496
+ "weight": form.get("weight") or None,
497
+ "protein": form.get("protein") or None,
498
+ "cuisine": form.get("cuisine") or None,
499
+ "grapes": form.get("grapes") or None,
500
+ }
501
+
502
+ from cellarbrain import pairing
503
+
504
+ grape_list = [g.strip() for g in params["grapes"].split(",")] if params["grapes"] else None
505
+ results = pairing.retrieve_candidates(
506
+ con,
507
+ dish_description=params["dish_description"],
508
+ category=params["category"],
509
+ weight=params["weight"],
510
+ protein=params["protein"],
511
+ cuisine=params["cuisine"],
512
+ grapes=grape_list,
513
+ limit=15,
514
+ )
515
+
516
+ template = "partials/pairing_results.html" if _wants_partial(request) else "pairing.html"
517
+ return _TEMPLATES.TemplateResponse(
518
+ request,
519
+ template,
520
+ context={"results": results, "params": params},
521
+ )
522
+
523
+
474
524
  # ---- SQL playground -------------------------------------------------------
475
525
 
476
526
  _QUICK_QUERIES = [
@@ -979,6 +1029,7 @@ def build_app(
979
1029
  Route("/cellar/{wine_id:int}", wine_detail),
980
1030
  Route("/bottles", cellar_bottles),
981
1031
  Route("/drinking", drinking),
1032
+ Route("/pairing", pairing_page, methods=["GET", "POST"]),
982
1033
  Route("/sql", sql_playground, methods=["GET", "POST"]),
983
1034
  Route("/stats", stats),
984
1035
  Route("/tracked", tracked),
@@ -0,0 +1,101 @@
1
+ /**
2
+ * dashboard.js — Chart.js render helpers for the observability dashboard.
3
+ * Loaded in base.html, called from inline <script> blocks in templates.
4
+ */
5
+
6
+ function renderBarChart(canvasId, apiUrl, label) {
7
+ fetch(apiUrl)
8
+ .then(r => r.json())
9
+ .then(d => {
10
+ new Chart(document.getElementById(canvasId), {
11
+ type: 'bar',
12
+ data: {
13
+ labels: d.labels,
14
+ datasets: [{
15
+ label: label || 'Calls',
16
+ data: d.data,
17
+ backgroundColor: 'rgba(99, 102, 241, 0.5)',
18
+ borderColor: 'rgb(99, 102, 241)',
19
+ borderWidth: 1,
20
+ }]
21
+ },
22
+ options: { responsive: true, scales: { y: { beginAtZero: true } } }
23
+ });
24
+ });
25
+ }
26
+
27
+ function renderLatencyChart(canvasId, apiUrl) {
28
+ fetch(apiUrl)
29
+ .then(r => r.json())
30
+ .then(d => {
31
+ new Chart(document.getElementById(canvasId), {
32
+ type: 'line',
33
+ data: {
34
+ labels: d.labels,
35
+ datasets: [
36
+ { label: 'P50', data: d.p50, borderColor: '#22c55e', fill: false },
37
+ { label: 'P95', data: d.p95, borderColor: '#ef4444', fill: false },
38
+ ]
39
+ },
40
+ options: { responsive: true, scales: { y: { beginAtZero: true } } }
41
+ });
42
+ });
43
+ }
44
+
45
+ function renderHistogram(canvasId, apiUrl) {
46
+ fetch(apiUrl)
47
+ .then(r => r.json())
48
+ .then(d => {
49
+ new Chart(document.getElementById(canvasId), {
50
+ type: 'bar',
51
+ data: {
52
+ labels: d.buckets,
53
+ datasets: [{
54
+ label: 'Count',
55
+ data: d.counts,
56
+ backgroundColor: 'rgba(234, 179, 8, 0.5)',
57
+ }]
58
+ },
59
+ options: { responsive: true, scales: { y: { beginAtZero: true } } }
60
+ });
61
+ });
62
+ }
63
+
64
+ function refreshConnection() {
65
+ fetch('/api/refresh', { method: 'POST' })
66
+ .then(r => r.json())
67
+ .then(() => window.location.reload());
68
+ }
69
+
70
+ function renderDoughnutChart(canvasId, labels, data) {
71
+ const colors = [
72
+ '#6366f1', '#ec4899', '#f59e0b', '#10b981',
73
+ '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#94a3b8'
74
+ ];
75
+ new Chart(document.getElementById(canvasId), {
76
+ type: 'doughnut',
77
+ data: {
78
+ labels: labels,
79
+ datasets: [{
80
+ data: data,
81
+ backgroundColor: colors.slice(0, data.length),
82
+ }]
83
+ },
84
+ options: { responsive: true, plugins: { legend: { position: 'right' } } }
85
+ });
86
+ }
87
+
88
+ function renderLineChart(canvasId, datasets) {
89
+ new Chart(document.getElementById(canvasId), {
90
+ type: 'line',
91
+ data: { datasets: datasets },
92
+ options: {
93
+ responsive: true,
94
+ scales: {
95
+ x: { type: 'category', title: { display: true, text: 'Month' } },
96
+ y: { beginAtZero: false, title: { display: true, text: 'Price (CHF)' } },
97
+ },
98
+ plugins: { legend: { position: 'top' } },
99
+ }
100
+ });
101
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * workbench.js — form enhancement and response rendering.
3
+ */
4
+
5
+ /* Copy response text to clipboard */
6
+ function copyResponse(elementId) {
7
+ var el = document.getElementById(elementId);
8
+ if (el) {
9
+ navigator.clipboard.writeText(el.textContent);
10
+ }
11
+ }
12
+
13
+ /* Toggle between raw and rendered view */
14
+ function toggleView(rawId, renderedId) {
15
+ var raw = document.getElementById(rawId);
16
+ var rendered = document.getElementById(renderedId);
17
+ if (raw) raw.classList.toggle('hidden');
18
+ if (rendered) rendered.classList.toggle('hidden');
19
+ }
20
+
21
+ /* Quick fill a form field */
22
+ function fillField(fieldName, value) {
23
+ var input = document.querySelector('[name="' + fieldName + '"]');
24
+ if (input) input.value = value;
25
+ }
@@ -0,0 +1,162 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{% block title %}Cellarbrain Explorer{% endblock %}</title>
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
8
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
9
+ <script src="https://unpkg.com/htmx.org@2.0.4"></script>
10
+ <script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
11
+ <style>
12
+ :root {
13
+ --pico-font-size: 14px;
14
+ }
15
+ nav .nav-group { display: inline-block; margin-right: 1.5rem; }
16
+ nav .nav-group-label {
17
+ font-size: 0.75rem; text-transform: uppercase;
18
+ color: var(--pico-muted-color); display: block; margin-bottom: 0.15rem;
19
+ }
20
+ nav a { margin-right: 0.5rem; font-size: 0.85rem; }
21
+ nav a.active { font-weight: bold; text-decoration: underline; }
22
+ nav a.disabled { color: var(--pico-muted-color); pointer-events: none; opacity: 0.5; }
23
+ .kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
24
+ .kpi-card { padding: 1rem; border-radius: 8px; background: var(--pico-card-background-color); border: 1px solid var(--pico-muted-border-color); text-align: center; }
25
+ .kpi-card .value { font-size: 1.8rem; font-weight: bold; }
26
+ .kpi-card .label { font-size: 0.8rem; color: var(--pico-muted-color); }
27
+ .period-selector { display: inline-flex; gap: 0.25rem; }
28
+ .period-selector a { padding: 0.2rem 0.6rem; border-radius: 4px; font-size: 0.8rem; text-decoration: none; }
29
+ .period-selector a.active { background: var(--pico-primary); color: white; }
30
+ .chart-container { position: relative; height: 250px; margin-bottom: 1.5rem; }
31
+ .toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem; }
32
+ table { font-size: 0.85rem; }
33
+ .badge { padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.75rem; }
34
+ .badge-ok { background: #dcfce7; color: #166534; }
35
+ .badge-warn { background: #fef9c3; color: #854d0e; }
36
+ .badge-error { background: #fecaca; color: #991b1b; }
37
+ .mono { font-family: monospace; font-size: 0.8rem; }
38
+ .truncate { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
39
+ .hidden { display: none; }
40
+ footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--pico-muted-border-color); font-size: 0.75rem; color: var(--pico-muted-color); }
41
+ /* Live tail event cards */
42
+ .event-card { padding: 0.3rem 0.5rem; border-bottom: 1px solid var(--pico-muted-border-color); font-size: 0.8rem; font-family: monospace; }
43
+ .event-card.error { background: #fef2f2; }
44
+ .event-card .ts { color: var(--pico-muted-color); }
45
+ .event-card .type { color: var(--pico-primary); }
46
+ .event-card .name { font-weight: 600; }
47
+ .event-card .status { margin-left: 0.3rem; }
48
+ .event-card .ms { color: var(--pico-muted-color); margin-left: 0.3rem; }
49
+ .turn-boundary { padding: 0.2rem 0.5rem; font-size: 0.7rem; color: var(--pico-muted-color); text-align: center; border-bottom: 1px solid var(--pico-muted-border-color); }
50
+ .live-toolbar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; }
51
+ .live-toolbar select { width: auto; padding: 0.2rem 0.4rem; font-size: 0.75rem; margin: 0; }
52
+ .live-toolbar button { font-size: 0.75rem; padding: 0.2rem 0.6rem; }
53
+ .status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 0.3rem; }
54
+ .status-dot.connected { background: #22c55e; }
55
+ .status-dot.disconnected { background: #ef4444; }
56
+ </style>
57
+ </head>
58
+ <body>
59
+ <main class="container-fluid">
60
+ <nav>
61
+ <div class="nav-group">
62
+ <span class="nav-group-label">Cellar</span>
63
+ <a href="/cellar" class="{% if active_nav == 'cellar' %}active{% endif %}">Wines</a>
64
+ <a href="/bottles" class="{% if active_nav == 'bottles' %}active{% endif %}">Bottles</a>
65
+ <a href="/tracked" class="{% if active_nav == 'tracked' %}active{% endif %}">Tracked</a>
66
+ <a href="/stats" class="{% if active_nav == 'stats' %}active{% endif %}">Stats</a>
67
+ <a href="/drinking" class="{% if active_nav == 'drinking' %}active{% endif %}">Drinking</a>
68
+ <a href="/pairing" class="{% if active_nav == 'pairing' %}active{% endif %}">Pairing</a>
69
+ <a href="/sql" class="{% if active_nav == 'sql' %}active{% endif %}">SQL</a>
70
+ </div>
71
+ <div class="nav-group">
72
+ <span class="nav-group-label">Server</span>
73
+ <a href="/" class="{% if active_nav == 'overview' %}active{% endif %}">Overview</a>
74
+ <a href="/tools" class="{% if active_nav == 'tools' %}active{% endif %}">Tool Usage</a>
75
+ <a href="/errors" class="{% if active_nav == 'errors' %}active{% endif %}">Errors</a>
76
+ <a href="/sessions" class="{% if active_nav == 'sessions' %}active{% endif %}">Sessions</a>
77
+ <a href="/latency" class="{% if active_nav == 'latency' %}active{% endif %}">Latency</a>
78
+ <a href="/live" class="{% if active_nav == 'live' %}active{% endif %}">Live</a>
79
+ </div>
80
+ <div class="nav-group">
81
+ <span class="nav-group-label">Workbench</span>
82
+ <a href="/workbench" class="{% if active_nav == 'workbench' %}active{% endif %}">Tools</a>
83
+ <a href="/workbench/batch" class="{% if active_nav == 'workbench_batch' %}active{% endif %}">Batch</a>
84
+ </div>
85
+ </nav>
86
+
87
+ <div class="toolbar">
88
+ <h4 style="margin:0">{% block page_title %}{% endblock %}</h4>
89
+ <div style="display:flex; align-items:center; gap:1rem;">
90
+ {% if period is defined %}
91
+ <div class="period-selector">
92
+ {% for p in ["1h", "24h", "7d", "30d"] %}
93
+ <a href="?period={{ p }}"
94
+ class="{% if period == p %}active{% endif %}"
95
+ hx-get="?period={{ p }}" hx-push-url="true" hx-target="body">{{ p }}</a>
96
+ {% endfor %}
97
+ </div>
98
+ {% endif %}
99
+ {% if include_workbench is defined %}
100
+ <label style="font-size:0.7rem; margin:0; display:flex; align-items:center; gap:0.25rem; cursor:pointer;">
101
+ <input type="checkbox" style="width:auto; margin:0;"
102
+ {% if include_workbench %}checked{% endif %}
103
+ onchange="var url=new URL(window.location); if(this.checked){url.searchParams.set('include_workbench','1')}else{url.searchParams.delete('include_workbench')}; window.location=url;">
104
+ Workbench
105
+ </label>
106
+ {% endif %}
107
+ <button class="outline secondary" style="font-size:0.75rem; padding:0.2rem 0.5rem;" onclick="refreshConnection()">Refresh</button>
108
+ </div>
109
+ </div>
110
+
111
+ {% block content %}{% endblock %}
112
+
113
+ <footer>
114
+ Cellarbrain Explorer &middot; Phase 5 &middot; Live Tail
115
+ {% if period is defined %}
116
+ <span style="margin-left:1.5rem;">
117
+ <label style="font-size:0.7rem; margin:0; display:inline-flex; align-items:center; gap:0.25rem; cursor:pointer;">
118
+ <input type="checkbox" id="auto-refresh-toggle" style="width:auto; margin:0;">
119
+ Auto-refresh (30 s)
120
+ </label>
121
+ </span>
122
+ {% endif %}
123
+ </footer>
124
+ </main>
125
+ <script src="/static/dashboard.js"></script>
126
+ <script>
127
+ /* Toggle active class for inline tab / selector groups on HTMX click */
128
+ document.addEventListener('click', function(e) {
129
+ var link = e.target.closest('.period-selector a, [data-toggle-group] a, [data-toggle-group] [role="button"]');
130
+ if (!link) return;
131
+ var group = link.closest('.period-selector, [data-toggle-group]');
132
+ if (!group) return;
133
+ /* period-selector: toggle .active */
134
+ if (group.classList.contains('period-selector')) {
135
+ group.querySelectorAll('a').forEach(function(a) { a.classList.remove('active'); });
136
+ link.classList.add('active');
137
+ } else {
138
+ /* button-style toggles (contrast / outline secondary) */
139
+ group.querySelectorAll('a[role="button"], button').forEach(function(a) {
140
+ a.className = a.className.replace('contrast', 'outline secondary');
141
+ });
142
+ link.className = link.className.replace('outline secondary', 'contrast');
143
+ }
144
+ });
145
+ </script>
146
+ <script>
147
+ (function(){
148
+ var toggle = document.getElementById('auto-refresh-toggle');
149
+ if (!toggle) return;
150
+ var timer = null;
151
+ toggle.addEventListener('change', function(){
152
+ if (this.checked) {
153
+ timer = setInterval(function(){ window.location.reload(); }, 30000);
154
+ } else {
155
+ if (timer) { clearInterval(timer); timer = null; }
156
+ }
157
+ });
158
+ })();
159
+ </script>
160
+ {% block scripts %}{% endblock %}
161
+ </body>
162
+ </html>
@@ -0,0 +1,68 @@
1
+ {% extends "base.html" %}
2
+
3
+ {% set active_nav = "bottles" %}
4
+ {% block title %}Bottle Inventory — Cellarbrain Explorer{% endblock %}
5
+ {% block page_title %}Bottle Inventory{% endblock %}
6
+
7
+ {% block content %}
8
+ <div style="display:flex; gap:0.5rem; margin-bottom:1rem; flex-wrap:wrap; align-items:center;">
9
+ <div class="period-selector">
10
+ {% for v, label in [("stored", "Stored"), ("on_order", "On Order"), ("consumed", "Consumed"), ("all", "All")] %}
11
+ <a href="/bottles?view={{ v }}&cellar={{ cellar_filter }}&category={{ category }}"
12
+ hx-get="/bottles?view={{ v }}&cellar={{ cellar_filter }}&category={{ category }}"
13
+ hx-target="#bottle-tbody" hx-swap="innerHTML"
14
+ class="{% if view == v %}active{% endif %}">{{ label }}</a>
15
+ {% endfor %}
16
+ </div>
17
+ <div style="margin-left:1rem;">
18
+ <select name="cellar" style="font-size:0.85rem; padding:0.2rem 0.5rem;"
19
+ hx-get="/bottles" hx-target="#bottle-tbody" hx-swap="innerHTML"
20
+ hx-include="[name='view_val'],[name='category']">
21
+ <option value="">All cellars</option>
22
+ {% for c in cellar_names %}
23
+ <option value="{{ c }}" {% if cellar_filter == c %}selected{% endif %}>{{ c }}</option>
24
+ {% endfor %}
25
+ </select>
26
+ <input type="hidden" name="view_val" value="{{ view }}">
27
+ </div>
28
+ </div>
29
+
30
+ <figure>
31
+ <table role="grid">
32
+ <thead>
33
+ <tr>
34
+ {% set base_params = "view=" ~ view ~ "&cellar=" ~ cellar_filter ~ "&category=" ~ category %}
35
+ {% for col_id, col_label in [("wine_name", "Wine"), ("vintage", "Vintage"), ("cellar_name", "Cellar"), ("shelf", "Shelf"), ("price", "Price"), ("status", "Status")] %}
36
+ <th>
37
+ <a href="/bottles?{{ base_params }}&sort={{ col_id }}&dir={% if sort == col_id and not desc %}desc{% else %}asc{% endif %}"
38
+ hx-get="/bottles?{{ base_params }}&sort={{ col_id }}&dir={% if sort == col_id and not desc %}desc{% else %}asc{% endif %}"
39
+ hx-target="#bottle-tbody" hx-swap="innerHTML">
40
+ {{ col_label }}{% if sort == col_id %}{{ " ▼" if desc else " ▲" }}{% endif %}
41
+ </a>
42
+ </th>
43
+ {% endfor %}
44
+ </tr>
45
+ </thead>
46
+ <tbody id="bottle-tbody">
47
+ {% include "partials/bottle_rows.html" %}
48
+ </tbody>
49
+ </table>
50
+ </figure>
51
+
52
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-top:1rem;">
53
+ <span style="font-size:0.85rem; color:var(--pico-muted-color);">
54
+ {{ total }} bottle{{ "s" if total != 1 }}
55
+ </span>
56
+ <div style="display:flex; gap:0.5rem; align-items:center;">
57
+ {% if page > 1 %}
58
+ <a href="/bottles?page={{ page - 1 }}&{{ base_params }}&sort={{ sort }}&dir={% if desc %}desc{% else %}asc{% endif %}"
59
+ role="button" class="outline secondary" style="font-size:0.8rem; padding:0.2rem 0.6rem;">← Prev</a>
60
+ {% endif %}
61
+ <span style="font-size:0.85rem;">Page {{ page }} of {{ total_pages }}</span>
62
+ {% if page < total_pages %}
63
+ <a href="/bottles?page={{ page + 1 }}&{{ base_params }}&sort={{ sort }}&dir={% if desc %}desc{% else %}asc{% endif %}"
64
+ role="button" class="outline secondary" style="font-size:0.8rem; padding:0.2rem 0.6rem;">Next →</a>
65
+ {% endif %}
66
+ </div>
67
+ </div>
68
+ {% endblock %}