insyte 0.1.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.
- insyte/__init__.py +12 -0
- insyte/analytics/__init__.py +21 -0
- insyte/analytics/charts.py +67 -0
- insyte/analytics/comparison.py +64 -0
- insyte/analytics/engine.py +195 -0
- insyte/analytics/forecast.py +58 -0
- insyte/analytics/models.py +90 -0
- insyte/analytics/periods.py +46 -0
- insyte/analytics/segmentation.py +40 -0
- insyte/cli/__init__.py +1 -0
- insyte/cli/_project.py +43 -0
- insyte/cli/_stubs.py +42 -0
- insyte/cli/analyze_command.py +165 -0
- insyte/cli/app.py +84 -0
- insyte/cli/chat_command.py +68 -0
- insyte/cli/connect_command.py +147 -0
- insyte/cli/doctor_command.py +127 -0
- insyte/cli/history_command.py +89 -0
- insyte/cli/init_command.py +268 -0
- insyte/cli/mcp_command.py +150 -0
- insyte/cli/metrics_command.py +95 -0
- insyte/cli/profile_command.py +128 -0
- insyte/cli/query_command.py +115 -0
- insyte/cli/scan_command.py +101 -0
- insyte/cli/schema_command.py +189 -0
- insyte/cli/semantic_command.py +101 -0
- insyte/cli/status_command.py +88 -0
- insyte/cli/studio_command.py +110 -0
- insyte/cli/sync_command.py +187 -0
- insyte/config/__init__.py +1 -0
- insyte/config/loader.py +96 -0
- insyte/config/models.py +153 -0
- insyte/config/paths.py +119 -0
- insyte/config/secrets.py +82 -0
- insyte/connectors/__init__.py +19 -0
- insyte/connectors/base.py +96 -0
- insyte/connectors/duckdb.py +74 -0
- insyte/connectors/factory.py +37 -0
- insyte/connectors/postgres.py +284 -0
- insyte/exceptions.py +112 -0
- insyte/logging_config.py +131 -0
- insyte/main.py +15 -0
- insyte/mcp/__init__.py +6 -0
- insyte/mcp/installer.py +130 -0
- insyte/mcp/server.py +91 -0
- insyte/mcp/tools.py +331 -0
- insyte/metadata/__init__.py +6 -0
- insyte/metadata/classifier.py +89 -0
- insyte/metadata/models.py +523 -0
- insyte/metadata/pii_detector.py +106 -0
- insyte/metadata/profiler.py +209 -0
- insyte/metadata/relationship_detector.py +164 -0
- insyte/metadata/repository.py +681 -0
- insyte/metadata/scanner.py +256 -0
- insyte/nl/__init__.py +7 -0
- insyte/nl/llm.py +304 -0
- insyte/nl/periods.py +92 -0
- insyte/query/__init__.py +13 -0
- insyte/query/cost_guard.py +66 -0
- insyte/query/executor.py +171 -0
- insyte/query/generator.py +200 -0
- insyte/query/models.py +72 -0
- insyte/query/validator.py +300 -0
- insyte/semantic/__init__.py +28 -0
- insyte/semantic/generator.py +197 -0
- insyte/semantic/models.py +72 -0
- insyte/semantic/repository.py +34 -0
- insyte/semantic/validator.py +117 -0
- insyte/services/__init__.py +30 -0
- insyte/services/analysis_service.py +43 -0
- insyte/services/conversation_service.py +79 -0
- insyte/services/export_service.py +25 -0
- insyte/services/history_service.py +19 -0
- insyte/services/metric_service.py +37 -0
- insyte/services/project_service.py +83 -0
- insyte/services/schema_service.py +86 -0
- insyte/studio/__init__.py +5 -0
- insyte/studio/app.py +107 -0
- insyte/studio/dependencies.py +26 -0
- insyte/studio/events.py +271 -0
- insyte/studio/routes/__init__.py +1 -0
- insyte/studio/routes/analysis.py +102 -0
- insyte/studio/routes/conversations.py +87 -0
- insyte/studio/routes/exports.py +37 -0
- insyte/studio/routes/history.py +37 -0
- insyte/studio/routes/metrics.py +50 -0
- insyte/studio/routes/project.py +71 -0
- insyte/studio/routes/schema.py +77 -0
- insyte/studio/routes/sync.py +37 -0
- insyte/studio/schemas.py +244 -0
- insyte/studio/static.py +31 -0
- insyte/studio_dist/assets/app.css +276 -0
- insyte/studio_dist/assets/app.js +635 -0
- insyte/studio_dist/assets/logo-dark.png +0 -0
- insyte/studio_dist/assets/logo-light.png +0 -0
- insyte/studio_dist/index.html +15 -0
- insyte/tui/__init__.py +6 -0
- insyte/tui/app.py +27 -0
- insyte/tui/controller.py +310 -0
- insyte/tui/intent.py +176 -0
- insyte/tui/screens/__init__.py +1 -0
- insyte/tui/screens/chat.py +123 -0
- insyte/tui/styles/app.tcss +101 -0
- insyte/tui/widgets/__init__.py +1 -0
- insyte/tui/widgets/chart_panel.py +38 -0
- insyte/tui/widgets/prompt_input.py +15 -0
- insyte/tui/widgets/result_card.py +63 -0
- insyte/tui/widgets/sidebar.py +27 -0
- insyte/tui/widgets/sql_panel.py +18 -0
- insyte/tui/widgets/status_bar.py +15 -0
- insyte/tui/widgets/table_panel.py +38 -0
- insyte/warehouse/__init__.py +8 -0
- insyte/warehouse/duckdb_manager.py +75 -0
- insyte/warehouse/extractor.py +116 -0
- insyte/warehouse/model_builder.py +20 -0
- insyte/warehouse/sync_engine.py +129 -0
- insyte/warehouse/sync_state.py +38 -0
- insyte-0.1.0.dist-info/METADATA +363 -0
- insyte-0.1.0.dist-info/RECORD +122 -0
- insyte-0.1.0.dist-info/WHEEL +4 -0
- insyte-0.1.0.dist-info/entry_points.txt +2 -0
- insyte-0.1.0.dist-info/licenses/LICENSE +201 -0
insyte/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Insyte — local-first AI analytics over your database, safely.
|
|
2
|
+
|
|
3
|
+
Insyte connects to a database using read-only credentials and lets you analyse it
|
|
4
|
+
through Claude Code, Codex, other MCP clients, or its own terminal UI. AI models never
|
|
5
|
+
receive database credentials and can never bypass the SQL validation pipeline.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Analytics: metric resolution, SQL generation, comparison, segmentation, and charts."""
|
|
2
|
+
|
|
3
|
+
from insyte.analytics.engine import AnalyticsEngine
|
|
4
|
+
from insyte.analytics.models import (
|
|
5
|
+
AnalysisKind,
|
|
6
|
+
AnalysisResult,
|
|
7
|
+
ChartType,
|
|
8
|
+
Period,
|
|
9
|
+
PeriodComparison,
|
|
10
|
+
TimeGrain,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AnalysisKind",
|
|
15
|
+
"AnalysisResult",
|
|
16
|
+
"AnalyticsEngine",
|
|
17
|
+
"ChartType",
|
|
18
|
+
"Period",
|
|
19
|
+
"PeriodComparison",
|
|
20
|
+
"TimeGrain",
|
|
21
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Chart recommendation and value formatting (spec §20).
|
|
2
|
+
|
|
3
|
+
Chart selection is a pure function of the analysis kind and result shape; a chart is only
|
|
4
|
+
recommended when it genuinely helps (never for a single aggregate value).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from insyte.analytics.models import AnalysisKind, ChartSpec, ChartType
|
|
10
|
+
from insyte.semantic.models import MetricFormat
|
|
11
|
+
|
|
12
|
+
# Above this many segments, a horizontal bar reads better than a vertical one.
|
|
13
|
+
_HORIZONTAL_BAR_THRESHOLD = 6
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def recommend_chart(
|
|
17
|
+
kind: AnalysisKind, columns: list[str], row_count: int, label: str
|
|
18
|
+
) -> ChartSpec:
|
|
19
|
+
"""Recommend a chart type for a result."""
|
|
20
|
+
|
|
21
|
+
if kind is AnalysisKind.timeseries and row_count >= 2:
|
|
22
|
+
return ChartSpec(ChartType.line, title=label, x_label=_col(columns, 0), y_label=label)
|
|
23
|
+
if kind is AnalysisKind.segment and row_count >= 1:
|
|
24
|
+
chart = ChartType.horizontal_bar if row_count > _HORIZONTAL_BAR_THRESHOLD else ChartType.bar
|
|
25
|
+
return ChartSpec(chart, title=label, x_label=_col(columns, 0), y_label=label)
|
|
26
|
+
return ChartSpec(ChartType.none, title=label)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _col(columns: list[str], index: int) -> str | None:
|
|
30
|
+
return columns[index] if index < len(columns) else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def format_value(value: object, fmt: MetricFormat) -> str:
|
|
34
|
+
"""Format a scalar metric value for display."""
|
|
35
|
+
|
|
36
|
+
if value is None:
|
|
37
|
+
return "—"
|
|
38
|
+
number = _as_float(value)
|
|
39
|
+
if number is None:
|
|
40
|
+
return str(value)
|
|
41
|
+
if fmt is MetricFormat.percent:
|
|
42
|
+
return f"{number * 100:.1f}%"
|
|
43
|
+
if fmt is MetricFormat.currency:
|
|
44
|
+
return _compact_number(number, prefix="₹")
|
|
45
|
+
return _compact_number(number)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _compact_number(number: float, prefix: str = "") -> str:
|
|
49
|
+
"""Compact number in the Indian system: thousand (K), lakh (L), crore (Cr)."""
|
|
50
|
+
|
|
51
|
+
magnitude = abs(number)
|
|
52
|
+
if magnitude >= 10_000_000: # 1 crore = 1,00,00,000
|
|
53
|
+
return f"{prefix}{number / 10_000_000:.2f} Cr"
|
|
54
|
+
if magnitude >= 100_000: # 1 lakh = 1,00,000
|
|
55
|
+
return f"{prefix}{number / 100_000:.2f} L"
|
|
56
|
+
if magnitude >= 1_000:
|
|
57
|
+
return f"{prefix}{number / 1_000:.1f} K"
|
|
58
|
+
if number == int(number):
|
|
59
|
+
return f"{prefix}{int(number)}"
|
|
60
|
+
return f"{prefix}{number:.2f}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _as_float(value: object) -> float | None:
|
|
64
|
+
try:
|
|
65
|
+
return float(value) # type: ignore[arg-type]
|
|
66
|
+
except (TypeError, ValueError):
|
|
67
|
+
return None
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Period comparison: compute deltas between a current and a baseline value."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from insyte.analytics.charts import format_value
|
|
6
|
+
from insyte.analytics.models import Period, PeriodComparison
|
|
7
|
+
from insyte.semantic.models import Metric
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def compute_comparison(
|
|
11
|
+
metric_name: str,
|
|
12
|
+
metric: Metric,
|
|
13
|
+
current: Period,
|
|
14
|
+
current_value: float | None,
|
|
15
|
+
baseline: Period,
|
|
16
|
+
baseline_value: float | None,
|
|
17
|
+
sql_current: str,
|
|
18
|
+
sql_baseline: str,
|
|
19
|
+
) -> PeriodComparison:
|
|
20
|
+
"""Assemble a :class:`PeriodComparison`, including absolute and percentage change."""
|
|
21
|
+
|
|
22
|
+
absolute: float | None = None
|
|
23
|
+
percent: float | None = None
|
|
24
|
+
if current_value is not None and baseline_value is not None:
|
|
25
|
+
absolute = current_value - baseline_value
|
|
26
|
+
percent = (absolute / baseline_value * 100.0) if baseline_value else None
|
|
27
|
+
|
|
28
|
+
return PeriodComparison(
|
|
29
|
+
metric=metric_name,
|
|
30
|
+
label=metric.label,
|
|
31
|
+
current=current,
|
|
32
|
+
baseline=baseline,
|
|
33
|
+
current_value=current_value,
|
|
34
|
+
baseline_value=baseline_value,
|
|
35
|
+
absolute_change=absolute,
|
|
36
|
+
percent_change=percent,
|
|
37
|
+
sql_current=sql_current,
|
|
38
|
+
sql_baseline=sql_baseline,
|
|
39
|
+
summary=_summarise(metric, current, baseline, current_value, baseline_value, percent),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _summarise(
|
|
44
|
+
metric: Metric,
|
|
45
|
+
current: Period,
|
|
46
|
+
baseline: Period,
|
|
47
|
+
current_value: float | None,
|
|
48
|
+
baseline_value: float | None,
|
|
49
|
+
percent: float | None,
|
|
50
|
+
) -> str:
|
|
51
|
+
if current_value is None or baseline_value is None:
|
|
52
|
+
return f"{metric.label}: not enough data to compare."
|
|
53
|
+
cur = format_value(current_value, metric.format)
|
|
54
|
+
base = format_value(baseline_value, metric.format)
|
|
55
|
+
if percent is None:
|
|
56
|
+
direction = "changed"
|
|
57
|
+
pct = ""
|
|
58
|
+
else:
|
|
59
|
+
direction = "increased" if percent >= 0 else "decreased"
|
|
60
|
+
pct = f" by {abs(percent):.1f}%"
|
|
61
|
+
return (
|
|
62
|
+
f"{metric.label} {direction}{pct}, from {base} ({baseline.label}) "
|
|
63
|
+
f"to {cur} ({current.label})."
|
|
64
|
+
)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""The analytics engine: turn a metric request into an executed, formatted result.
|
|
2
|
+
|
|
3
|
+
The engine resolves a metric (and optional dimension) from the semantic layer, generates safe
|
|
4
|
+
SQL, runs it through the Milestone 4 executor (validation + audit + read-only execution), then
|
|
5
|
+
formats the rows and recommends a chart.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
from insyte.analytics.charts import format_value, recommend_chart
|
|
13
|
+
from insyte.analytics.comparison import compute_comparison
|
|
14
|
+
from insyte.analytics.models import (
|
|
15
|
+
AnalysisKind,
|
|
16
|
+
AnalysisResult,
|
|
17
|
+
Period,
|
|
18
|
+
PeriodComparison,
|
|
19
|
+
TimeGrain,
|
|
20
|
+
)
|
|
21
|
+
from insyte.analytics.segmentation import rank_contributors
|
|
22
|
+
from insyte.exceptions import DimensionNotFoundError, MetricNotFoundError
|
|
23
|
+
from insyte.metadata.models import RelationshipInfo
|
|
24
|
+
from insyte.query.executor import QueryExecutor
|
|
25
|
+
from insyte.query.generator import aggregate_sql, segment_sql, timeseries_sql
|
|
26
|
+
from insyte.query.models import ExecutionResult
|
|
27
|
+
from insyte.semantic.models import Dimension, Metric, SemanticLayer
|
|
28
|
+
|
|
29
|
+
_SOURCE = "analytics"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AnalyticsEngine:
|
|
33
|
+
"""Answer structured analytical questions against a project."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
executor: QueryExecutor,
|
|
38
|
+
layer: SemanticLayer,
|
|
39
|
+
relationships: list[RelationshipInfo],
|
|
40
|
+
) -> None:
|
|
41
|
+
self._executor = executor
|
|
42
|
+
self._layer = layer
|
|
43
|
+
self._relationships = relationships
|
|
44
|
+
|
|
45
|
+
# -- resolution --------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def _metric(self, name: str) -> Metric:
|
|
48
|
+
metric = self._layer.metrics.get(name)
|
|
49
|
+
if metric is None:
|
|
50
|
+
raise MetricNotFoundError(name)
|
|
51
|
+
return metric
|
|
52
|
+
|
|
53
|
+
def _dimension(self, name: str) -> Dimension:
|
|
54
|
+
dimension = self._layer.dimensions.get(name)
|
|
55
|
+
if dimension is None:
|
|
56
|
+
raise DimensionNotFoundError(name)
|
|
57
|
+
return dimension
|
|
58
|
+
|
|
59
|
+
# -- analyses ----------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def aggregate(self, metric_name: str, period: Period | None = None) -> AnalysisResult:
|
|
62
|
+
metric = self._metric(metric_name)
|
|
63
|
+
start, end = _bounds(period)
|
|
64
|
+
sql = aggregate_sql(metric, start, end)
|
|
65
|
+
execution = self._executor.execute(sql, source=_SOURCE)
|
|
66
|
+
value = _scalar(execution)
|
|
67
|
+
formatted = format_value(value, metric.format)
|
|
68
|
+
suffix = f" ({period.label})" if period else ""
|
|
69
|
+
return AnalysisResult(
|
|
70
|
+
kind=AnalysisKind.aggregate,
|
|
71
|
+
metric=metric_name,
|
|
72
|
+
label=metric.label,
|
|
73
|
+
columns=execution.columns,
|
|
74
|
+
rows=execution.rows,
|
|
75
|
+
formatted_rows=[[formatted]],
|
|
76
|
+
sql=execution.normalized_sql,
|
|
77
|
+
chart=recommend_chart(AnalysisKind.aggregate, execution.columns, 1, metric.label),
|
|
78
|
+
summary=f"{metric.label}: {formatted}{suffix}.",
|
|
79
|
+
row_count=execution.row_count,
|
|
80
|
+
duration_ms=execution.duration_ms,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def timeseries(
|
|
84
|
+
self, metric_name: str, grain: TimeGrain, period: Period | None = None
|
|
85
|
+
) -> AnalysisResult:
|
|
86
|
+
metric = self._metric(metric_name)
|
|
87
|
+
start, end = _bounds(period)
|
|
88
|
+
sql = timeseries_sql(metric, grain.value, start, end)
|
|
89
|
+
execution = self._executor.execute(sql, source=_SOURCE)
|
|
90
|
+
formatted = _format_rows(execution.rows, value_index=1, metric=metric)
|
|
91
|
+
latest = format_value(execution.rows[-1][1], metric.format) if execution.rows else "—"
|
|
92
|
+
return AnalysisResult(
|
|
93
|
+
kind=AnalysisKind.timeseries,
|
|
94
|
+
metric=metric_name,
|
|
95
|
+
label=metric.label,
|
|
96
|
+
columns=execution.columns,
|
|
97
|
+
rows=execution.rows,
|
|
98
|
+
formatted_rows=formatted,
|
|
99
|
+
sql=execution.normalized_sql,
|
|
100
|
+
chart=recommend_chart(
|
|
101
|
+
AnalysisKind.timeseries, execution.columns, execution.row_count, metric.label
|
|
102
|
+
),
|
|
103
|
+
summary=(
|
|
104
|
+
f"{metric.label} by {grain.value}: {execution.row_count} buckets; latest {latest}."
|
|
105
|
+
),
|
|
106
|
+
row_count=execution.row_count,
|
|
107
|
+
duration_ms=execution.duration_ms,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def segment(
|
|
111
|
+
self,
|
|
112
|
+
metric_name: str,
|
|
113
|
+
dimension_name: str,
|
|
114
|
+
period: Period | None = None,
|
|
115
|
+
limit: int = 20,
|
|
116
|
+
) -> AnalysisResult:
|
|
117
|
+
metric = self._metric(metric_name)
|
|
118
|
+
dimension = self._dimension(dimension_name)
|
|
119
|
+
start, end = _bounds(period)
|
|
120
|
+
sql = segment_sql(metric, dimension, self._relationships, limit=limit, start=start, end=end)
|
|
121
|
+
execution = self._executor.execute(sql, source=_SOURCE)
|
|
122
|
+
contributors = rank_contributors(execution.rows)
|
|
123
|
+
formatted = _format_rows(execution.rows, value_index=1, metric=metric)
|
|
124
|
+
summary = _segment_summary(metric, dimension_name, contributors)
|
|
125
|
+
return AnalysisResult(
|
|
126
|
+
kind=AnalysisKind.segment,
|
|
127
|
+
metric=metric_name,
|
|
128
|
+
label=metric.label,
|
|
129
|
+
columns=execution.columns,
|
|
130
|
+
rows=execution.rows,
|
|
131
|
+
formatted_rows=formatted,
|
|
132
|
+
sql=execution.normalized_sql,
|
|
133
|
+
chart=recommend_chart(
|
|
134
|
+
AnalysisKind.segment, execution.columns, execution.row_count, metric.label
|
|
135
|
+
),
|
|
136
|
+
summary=summary,
|
|
137
|
+
row_count=execution.row_count,
|
|
138
|
+
duration_ms=execution.duration_ms,
|
|
139
|
+
contributors=contributors,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def compare(self, metric_name: str, current: Period, baseline: Period) -> PeriodComparison:
|
|
143
|
+
metric = self._metric(metric_name)
|
|
144
|
+
sql_current = aggregate_sql(metric, current.start, current.end)
|
|
145
|
+
sql_baseline = aggregate_sql(metric, baseline.start, baseline.end)
|
|
146
|
+
current_value = _scalar(self._executor.execute(sql_current, source=_SOURCE))
|
|
147
|
+
baseline_value = _scalar(self._executor.execute(sql_baseline, source=_SOURCE))
|
|
148
|
+
return compute_comparison(
|
|
149
|
+
metric_name,
|
|
150
|
+
metric,
|
|
151
|
+
current,
|
|
152
|
+
current_value,
|
|
153
|
+
baseline,
|
|
154
|
+
baseline_value,
|
|
155
|
+
sql_current,
|
|
156
|
+
sql_baseline,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _bounds(period: Period | None) -> tuple[datetime | None, datetime | None]:
|
|
161
|
+
return (None, None) if period is None else (period.start, period.end)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _scalar(result: ExecutionResult) -> float | None:
|
|
165
|
+
if not result.rows or not result.rows[0]:
|
|
166
|
+
return None
|
|
167
|
+
try:
|
|
168
|
+
return float(result.rows[0][0]) # type: ignore[arg-type]
|
|
169
|
+
except (TypeError, ValueError):
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _format_rows(
|
|
174
|
+
rows: list[tuple[object, ...]], *, value_index: int, metric: Metric
|
|
175
|
+
) -> list[list[str]]:
|
|
176
|
+
formatted: list[list[str]] = []
|
|
177
|
+
for row in rows:
|
|
178
|
+
cells = []
|
|
179
|
+
for index, cell in enumerate(row):
|
|
180
|
+
if index == value_index:
|
|
181
|
+
cells.append(format_value(cell, metric.format))
|
|
182
|
+
elif cell is None:
|
|
183
|
+
cells.append("—")
|
|
184
|
+
else:
|
|
185
|
+
cells.append(str(cell))
|
|
186
|
+
formatted.append(cells)
|
|
187
|
+
return formatted
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _segment_summary(metric: Metric, dimension: str, contributors: list) -> str:
|
|
191
|
+
if not contributors:
|
|
192
|
+
return f"{metric.label} by {dimension}: no data."
|
|
193
|
+
top = contributors[0]
|
|
194
|
+
value = format_value(top.value, metric.format)
|
|
195
|
+
return f"{metric.label} by {dimension}: '{top.segment}' leads with {value} ({top.share:.0%})."
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Deterministic year-end projection from real monthly actuals.
|
|
2
|
+
|
|
3
|
+
This is *not* a model guess — it is arithmetic on the metric's own history: completed months of
|
|
4
|
+
the current year plus a trailing run-rate for the months not yet elapsed. The result is always
|
|
5
|
+
labelled an estimate. No AI is involved; the LLM only recognises that the user asked for a
|
|
6
|
+
forecast and routes here, Insyte computes it from queried rows.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
_RUN_RATE_MONTHS = 3
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class YearProjection:
|
|
19
|
+
"""A projected full-year total for a metric, with the inputs that produced it."""
|
|
20
|
+
|
|
21
|
+
year: int
|
|
22
|
+
ytd_actual: float # sum of completed months in the current year
|
|
23
|
+
projected_total: float # ytd_actual + run_rate * remaining months
|
|
24
|
+
run_rate: float # average of the last N completed months (any year)
|
|
25
|
+
complete_months: int # completed months of the current year
|
|
26
|
+
remaining_months: int # months not yet complete (incl. the current partial month)
|
|
27
|
+
basis_months: int # how many months the run-rate averaged over
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def project_current_year(
|
|
31
|
+
points: list[tuple[datetime, float]], now: datetime
|
|
32
|
+
) -> YearProjection | None:
|
|
33
|
+
"""Project the current calendar year from monthly ``(period_start, value)`` actuals."""
|
|
34
|
+
|
|
35
|
+
monthly = sorted((d, v) for d, v in points if v is not None)
|
|
36
|
+
if not monthly:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
# "Completed" = months strictly before the current month (the current month is partial).
|
|
40
|
+
completed = [(d, v) for d, v in monthly if (d.year, d.month) < (now.year, now.month)]
|
|
41
|
+
ytd_actual = sum(v for d, v in completed if d.year == now.year)
|
|
42
|
+
|
|
43
|
+
basis = completed[-_RUN_RATE_MONTHS:]
|
|
44
|
+
run_rate = sum(v for _, v in basis) / len(basis) if basis else 0.0
|
|
45
|
+
|
|
46
|
+
complete_months = now.month - 1
|
|
47
|
+
remaining_months = 12 - complete_months # includes the current, still-incomplete month
|
|
48
|
+
projected_total = ytd_actual + run_rate * remaining_months
|
|
49
|
+
|
|
50
|
+
return YearProjection(
|
|
51
|
+
year=now.year,
|
|
52
|
+
ytd_actual=ytd_actual,
|
|
53
|
+
projected_total=projected_total,
|
|
54
|
+
run_rate=run_rate,
|
|
55
|
+
complete_months=complete_months,
|
|
56
|
+
remaining_months=remaining_months,
|
|
57
|
+
basis_months=len(basis),
|
|
58
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Data structures for analytical requests and results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TimeGrain(StrEnum):
|
|
11
|
+
day = "day"
|
|
12
|
+
week = "week"
|
|
13
|
+
month = "month"
|
|
14
|
+
quarter = "quarter"
|
|
15
|
+
year = "year"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AnalysisKind(StrEnum):
|
|
19
|
+
aggregate = "aggregate"
|
|
20
|
+
timeseries = "timeseries"
|
|
21
|
+
segment = "segment"
|
|
22
|
+
comparison = "comparison"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ChartType(StrEnum):
|
|
26
|
+
line = "line"
|
|
27
|
+
bar = "bar"
|
|
28
|
+
horizontal_bar = "horizontal_bar"
|
|
29
|
+
pie = "pie"
|
|
30
|
+
scatter = "scatter"
|
|
31
|
+
none = "none"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ChartSpec:
|
|
36
|
+
"""A recommendation for how to chart a result (spec §20)."""
|
|
37
|
+
|
|
38
|
+
type: ChartType
|
|
39
|
+
title: str
|
|
40
|
+
x_label: str | None = None
|
|
41
|
+
y_label: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Period:
|
|
46
|
+
"""A named, half-open time range [start, end)."""
|
|
47
|
+
|
|
48
|
+
label: str
|
|
49
|
+
start: datetime
|
|
50
|
+
end: datetime
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Contributor:
|
|
55
|
+
"""A single segment's contribution to a metric."""
|
|
56
|
+
|
|
57
|
+
segment: str
|
|
58
|
+
value: float
|
|
59
|
+
share: float # fraction of the total, 0..1
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class AnalysisResult:
|
|
64
|
+
kind: AnalysisKind
|
|
65
|
+
metric: str
|
|
66
|
+
label: str
|
|
67
|
+
columns: list[str]
|
|
68
|
+
rows: list[tuple[object, ...]]
|
|
69
|
+
formatted_rows: list[list[str]]
|
|
70
|
+
sql: str
|
|
71
|
+
chart: ChartSpec
|
|
72
|
+
summary: str
|
|
73
|
+
row_count: int
|
|
74
|
+
duration_ms: float
|
|
75
|
+
contributors: list[Contributor] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class PeriodComparison:
|
|
80
|
+
metric: str
|
|
81
|
+
label: str
|
|
82
|
+
current: Period
|
|
83
|
+
baseline: Period
|
|
84
|
+
current_value: float | None
|
|
85
|
+
baseline_value: float | None
|
|
86
|
+
absolute_change: float | None
|
|
87
|
+
percent_change: float | None
|
|
88
|
+
sql_current: str
|
|
89
|
+
sql_baseline: str
|
|
90
|
+
summary: str
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Compute current/previous period pairs for a time grain (for comparisons)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
|
|
7
|
+
from insyte.analytics.models import Period, TimeGrain
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def periods_for_grain(grain: TimeGrain, *, now: datetime | None = None) -> tuple[Period, Period]:
|
|
11
|
+
"""Return ``(current, previous)`` periods for the grain, relative to ``now`` (or UTC now)."""
|
|
12
|
+
|
|
13
|
+
now = now or datetime.now(UTC)
|
|
14
|
+
if grain is TimeGrain.day:
|
|
15
|
+
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
16
|
+
return _pair(start, timedelta(days=1), "%Y-%m-%d")
|
|
17
|
+
if grain is TimeGrain.week:
|
|
18
|
+
midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
19
|
+
start = midnight - timedelta(days=midnight.weekday())
|
|
20
|
+
return _pair(start, timedelta(weeks=1), "%Y-%m-%d")
|
|
21
|
+
if grain is TimeGrain.year:
|
|
22
|
+
start = datetime(now.year, 1, 1, tzinfo=UTC)
|
|
23
|
+
return (
|
|
24
|
+
Period(str(now.year), start, datetime(now.year + 1, 1, 1, tzinfo=UTC)),
|
|
25
|
+
Period(str(now.year - 1), datetime(now.year - 1, 1, 1, tzinfo=UTC), start),
|
|
26
|
+
)
|
|
27
|
+
months = 3 if grain is TimeGrain.quarter else 1
|
|
28
|
+
start = datetime(now.year, now.month, 1, tzinfo=UTC)
|
|
29
|
+
end = _add_months(start, months)
|
|
30
|
+
previous = _add_months(start, -months)
|
|
31
|
+
return (
|
|
32
|
+
Period(start.strftime("%b %Y"), start, end),
|
|
33
|
+
Period(previous.strftime("%b %Y"), previous, start),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _pair(start: datetime, span: timedelta, fmt: str) -> tuple[Period, Period]:
|
|
38
|
+
return (
|
|
39
|
+
Period(start.strftime(fmt), start, start + span),
|
|
40
|
+
Period((start - span).strftime(fmt), start - span, start),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _add_months(value: datetime, months: int) -> datetime:
|
|
45
|
+
index = value.month - 1 + months
|
|
46
|
+
return datetime(value.year + index // 12, index % 12 + 1, 1, tzinfo=UTC)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Segmentation helpers: rank a metric's breakdown by contribution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from insyte.analytics.models import Contributor
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def rank_contributors(rows: list[tuple[object, ...]]) -> list[Contributor]:
|
|
9
|
+
"""Turn ``(segment, value)`` rows into contributors ranked by absolute value.
|
|
10
|
+
|
|
11
|
+
``share`` is each segment's fraction of the total of positive values.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
pairs: list[tuple[str, float]] = []
|
|
15
|
+
for row in rows:
|
|
16
|
+
if len(row) < 2:
|
|
17
|
+
continue
|
|
18
|
+
value = _as_float(row[1])
|
|
19
|
+
if value is None:
|
|
20
|
+
continue
|
|
21
|
+
pairs.append((_as_str(row[0]), value))
|
|
22
|
+
|
|
23
|
+
total = sum(v for _, v in pairs if v > 0)
|
|
24
|
+
contributors = [
|
|
25
|
+
Contributor(segment=segment, value=value, share=(value / total if total else 0.0))
|
|
26
|
+
for segment, value in pairs
|
|
27
|
+
]
|
|
28
|
+
contributors.sort(key=lambda c: abs(c.value), reverse=True)
|
|
29
|
+
return contributors
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _as_float(value: object) -> float | None:
|
|
33
|
+
try:
|
|
34
|
+
return float(value) # type: ignore[arg-type]
|
|
35
|
+
except (TypeError, ValueError):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _as_str(value: object) -> str:
|
|
40
|
+
return "—" if value is None else str(value)
|
insyte/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command-line interface package."""
|
insyte/cli/_project.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Shared helpers for commands that operate on a project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from insyte.config import loader, paths
|
|
9
|
+
from insyte.config.models import InsyteConfig
|
|
10
|
+
from insyte.exceptions import InsyteError
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resolve_config(project: str | None) -> InsyteConfig:
|
|
16
|
+
"""Load the requested project's config, or the active/only one.
|
|
17
|
+
|
|
18
|
+
Prints a friendly message and raises ``typer.Exit(1)`` when no project can be resolved.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
projects = loader.list_projects()
|
|
22
|
+
if not projects:
|
|
23
|
+
console.print(
|
|
24
|
+
"[yellow]No Insyte projects found.[/yellow] Run [bold]insyte init[/bold] first."
|
|
25
|
+
)
|
|
26
|
+
raise typer.Exit(1)
|
|
27
|
+
|
|
28
|
+
name = project or paths.get_active_project() or projects[0]
|
|
29
|
+
if name not in projects:
|
|
30
|
+
# On case-insensitive filesystems (macOS default) the on-disk folder and the stored
|
|
31
|
+
# active-project name can differ only by case — match case-insensitively before failing.
|
|
32
|
+
matches = [p for p in projects if p.lower() == name.lower()]
|
|
33
|
+
if matches:
|
|
34
|
+
name = matches[0]
|
|
35
|
+
else:
|
|
36
|
+
console.print(f"[red]Error:[/red] project {name!r} does not exist.")
|
|
37
|
+
raise typer.Exit(1)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
return loader.load_config(name)
|
|
41
|
+
except InsyteError as exc:
|
|
42
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
43
|
+
raise typer.Exit(1) from exc
|
insyte/cli/_stubs.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Registration of not-yet-implemented commands.
|
|
2
|
+
|
|
3
|
+
Every command from the product spec is registered so ``insyte --help`` shows the full
|
|
4
|
+
roadmap. Unimplemented commands print a clean notice pointing at the milestone that will
|
|
5
|
+
deliver them, and exit successfully.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
# command name -> (milestone, help text)
|
|
18
|
+
_STUBS: dict[str, tuple[int, str]] = {
|
|
19
|
+
"serve": (7, "Run the Insyte HTTP API"),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _coming_soon(command: str, milestone: int) -> None:
|
|
24
|
+
console.print(
|
|
25
|
+
f"[yellow]🚧 [bold]insyte {command}[/bold] is coming in Milestone {milestone}.[/yellow]"
|
|
26
|
+
)
|
|
27
|
+
console.print("[dim]This command is registered but not yet implemented.[/dim]")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _make_stub(command: str, milestone: int) -> Callable[[], None]:
|
|
31
|
+
def _stub() -> None:
|
|
32
|
+
_coming_soon(command, milestone)
|
|
33
|
+
|
|
34
|
+
_stub.__name__ = f"stub_{command.replace(' ', '_')}"
|
|
35
|
+
return _stub
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def register_stub_commands(app: typer.Typer) -> None:
|
|
39
|
+
"""Register all stubbed commands (and the ``mcp`` sub-app) on the given Typer app."""
|
|
40
|
+
|
|
41
|
+
for name, (milestone, help_text) in _STUBS.items():
|
|
42
|
+
app.command(name, help=f"{help_text} (Milestone {milestone})")(_make_stub(name, milestone))
|