predictability-engine 0.6.6
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.
- checksums.yaml +7 -0
- data/bin/predictability-engine +19 -0
- data/bin/predictability-engine.bat +2 -0
- data/bin/setup +47 -0
- data/data/samples/sample_data.csv +19 -0
- data/data/samples/sample_data_large.csv +201 -0
- data/data/samples/wip_data.csv +5 -0
- data/lib/predictability_engine/agents/assistant.rb +29 -0
- data/lib/predictability_engine/agents/tools.rb +98 -0
- data/lib/predictability_engine/calculators/aging.rb +36 -0
- data/lib/predictability_engine/calculators/cfd.rb +80 -0
- data/lib/predictability_engine/calculators/cfd_forecaster.rb +112 -0
- data/lib/predictability_engine/calculators/cycle_time.rb +26 -0
- data/lib/predictability_engine/calculators/throughput.rb +42 -0
- data/lib/predictability_engine/cli.rb +414 -0
- data/lib/predictability_engine/config.rb +167 -0
- data/lib/predictability_engine/data_generator.rb +53 -0
- data/lib/predictability_engine/data_manager.rb +28 -0
- data/lib/predictability_engine/data_sources/base.rb +93 -0
- data/lib/predictability_engine/data_sources/csv.rb +62 -0
- data/lib/predictability_engine/data_sources/excel.rb +18 -0
- data/lib/predictability_engine/data_sources/factory.rb +20 -0
- data/lib/predictability_engine/data_sources/jira.rb +201 -0
- data/lib/predictability_engine/data_sources/jira_yaml.rb +103 -0
- data/lib/predictability_engine/duration.rb +16 -0
- data/lib/predictability_engine/excel_exporter.rb +48 -0
- data/lib/predictability_engine/html_style.rb +63 -0
- data/lib/predictability_engine/html_templates.rb +70 -0
- data/lib/predictability_engine/jira_auth/base.rb +26 -0
- data/lib/predictability_engine/jira_auth/basic.rb +15 -0
- data/lib/predictability_engine/jira_auth/bearer.rb +11 -0
- data/lib/predictability_engine/jira_auth/cookie.rb +15 -0
- data/lib/predictability_engine/jira_auth/mfa_api.rb +36 -0
- data/lib/predictability_engine/jira_auth/mfa_browser.rb +85 -0
- data/lib/predictability_engine/jira_auth.rb +22 -0
- data/lib/predictability_engine/jira_config_prompter.rb +48 -0
- data/lib/predictability_engine/jira_workflow.rb +137 -0
- data/lib/predictability_engine/logger.rb +45 -0
- data/lib/predictability_engine/mermaid_visualizer.rb +94 -0
- data/lib/predictability_engine/models/work_item.rb +43 -0
- data/lib/predictability_engine/pdf_visualizer/primitives.rb +83 -0
- data/lib/predictability_engine/pdf_visualizer.rb +64 -0
- data/lib/predictability_engine/raw_data_exporter.rb +46 -0
- data/lib/predictability_engine/report/constants.rb +70 -0
- data/lib/predictability_engine/report/image_generator.rb +36 -0
- data/lib/predictability_engine/report/text_renderer.rb +84 -0
- data/lib/predictability_engine/report.rb +328 -0
- data/lib/predictability_engine/report_generator.rb +170 -0
- data/lib/predictability_engine/setup_manager.rb +127 -0
- data/lib/predictability_engine/simulators/monte_carlo.rb +57 -0
- data/lib/predictability_engine/simulators/monte_carlo_validator.rb +85 -0
- data/lib/predictability_engine/summary_visualizer/helpers.rb +71 -0
- data/lib/predictability_engine/summary_visualizer/renderer.rb +88 -0
- data/lib/predictability_engine/summary_visualizer.rb +36 -0
- data/lib/predictability_engine/terminal_visualizer/cfd_renderer.rb +52 -0
- data/lib/predictability_engine/terminal_visualizer.rb +97 -0
- data/lib/predictability_engine/vega_visualizer/aging_wip_visualizer.rb +44 -0
- data/lib/predictability_engine/vega_visualizer/basic_charts.rb +121 -0
- data/lib/predictability_engine/vega_visualizer/cfd_charts.rb +82 -0
- data/lib/predictability_engine/vega_visualizer/cfd_layout.rb +132 -0
- data/lib/predictability_engine/vega_visualizer/tooltip_helpers.rb +34 -0
- data/lib/predictability_engine/vega_visualizer.rb +106 -0
- data/lib/predictability_engine/version.rb +5 -0
- data/lib/predictability_engine/visualizer.rb +114 -0
- data/lib/predictability_engine.rb +117 -0
- metadata +566 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
module PredictabilityEngine
|
|
6
|
+
module Calculators
|
|
7
|
+
module CfdForecaster
|
|
8
|
+
DEFAULT_HISTORY_RANGE = '2m'
|
|
9
|
+
|
|
10
|
+
def self.forecast_summary(work_items, trials: 10_000, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES)
|
|
11
|
+
backlog = work_items.reject(&:completed?).size
|
|
12
|
+
historical = Throughput.daily(work_items).values
|
|
13
|
+
return nil if historical.empty?
|
|
14
|
+
return nil if backlog.zero? && work_items.none? { |i| i.end_date && i.end_date > PredictabilityEngine.today }
|
|
15
|
+
|
|
16
|
+
results = simulate_backlog(backlog, historical, trials)
|
|
17
|
+
days_to_future = days_to_last_scheduled_event(work_items)
|
|
18
|
+
|
|
19
|
+
build_summary(work_items, results, days_to_future, percentiles)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.forecast_series(work_items, trials: 10_000, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES,
|
|
23
|
+
history_range: nil)
|
|
24
|
+
cfd_data = ensure_data_up_to_today(Cfd.calculate(work_items), work_items)
|
|
25
|
+
return nil if cfd_data.empty?
|
|
26
|
+
|
|
27
|
+
summary = forecast_summary(work_items, trials: trials, percentiles: percentiles)
|
|
28
|
+
return nil unless summary
|
|
29
|
+
|
|
30
|
+
history, future_data, max_days = slice_series(cfd_data, summary, percentiles, history_range)
|
|
31
|
+
{ dates: build_dates(history, max_days), arrivals: build_arrivals(history, max_days, future_data),
|
|
32
|
+
departed: history.map { |d| d[:departed] }, summary: summary, max_days: max_days,
|
|
33
|
+
forecasts: build_forecast_map(history, summary, max_days, percentiles, future_data) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.slice_series(cfd_data, summary, percentiles, history_range)
|
|
37
|
+
today_index = cfd_data.index { |d| d[:date] == PredictabilityEngine.today } || (cfd_data.size - 1)
|
|
38
|
+
history_days = Duration.parse(history_range || DEFAULT_HISTORY_RANGE)
|
|
39
|
+
history = cfd_data[0..today_index].last(history_days)
|
|
40
|
+
future_data = cfd_data[(today_index + 1)..] || []
|
|
41
|
+
max_days = [percentiles.map { |p| summary[:"p#{p}"] }.max || 0, future_data.size].max
|
|
42
|
+
[history, future_data, max_days]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.simulate_backlog(backlog, historical, trials)
|
|
46
|
+
return [0] * trials if backlog.zero?
|
|
47
|
+
|
|
48
|
+
Simulators::MonteCarlo.when_will_it_be_done(backlog, historical, trials: trials)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.days_to_last_scheduled_event(work_items)
|
|
52
|
+
arrival_dates = future_dates(work_items, :start_date)
|
|
53
|
+
departure_dates = future_dates(work_items, :end_date)
|
|
54
|
+
future = arrival_dates + departure_dates
|
|
55
|
+
future.map { |d| (d - PredictabilityEngine.today).to_i }.max || 0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.future_dates(work_items, field)
|
|
59
|
+
work_items.map { |i| i.send(field) }.compact.select { |d| d > PredictabilityEngine.today }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.build_summary(work_items, results, days_to_future, percentiles)
|
|
63
|
+
res = { wip: work_items.reject(&:completed?).size, today: PredictabilityEngine.today,
|
|
64
|
+
total_items: work_items.size, departed_so_far: work_items.count(&:completed?) }
|
|
65
|
+
percentiles.each do |p|
|
|
66
|
+
sim_days = Simulators::MonteCarlo.percentile(results, p)
|
|
67
|
+
res[:"p#{p}"] = [sim_days, days_to_future].max
|
|
68
|
+
end
|
|
69
|
+
res
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.ensure_data_up_to_today(cfd_data, work_items)
|
|
73
|
+
if cfd_data.empty? || cfd_data.last[:date] < PredictabilityEngine.today
|
|
74
|
+
return Cfd.calculate(work_items, end_date: PredictabilityEngine.today)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
cfd_data
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.build_dates(history, max_days)
|
|
81
|
+
dates = history.map { |d| d[:date] }
|
|
82
|
+
(1..max_days).each { |i| dates << (history.last[:date] + i) }
|
|
83
|
+
dates
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.build_arrivals(history, max_days, future_data)
|
|
87
|
+
arrivals = history.map { |d| d[:arrived] }
|
|
88
|
+
(1..max_days).each do |i|
|
|
89
|
+
arrivals << (future_data[i - 1] ? future_data[i - 1][:arrived] : history.last[:arrived])
|
|
90
|
+
end
|
|
91
|
+
arrivals
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.build_forecast_map(history, summary, max_days, percentiles, future_data)
|
|
95
|
+
percentiles.each_with_object({}) do |p, h|
|
|
96
|
+
days = summary[:"p#{p}"]
|
|
97
|
+
res = history.map { |d| d[:departed] }
|
|
98
|
+
(1..max_days).each do |i|
|
|
99
|
+
fd = future_data[i - 1] ? future_data[i - 1][:departed] : summary[:departed_so_far]
|
|
100
|
+
forecasted = i <= days ? (i * (summary[:wip].to_f / days)) : summary[:wip]
|
|
101
|
+
res << (fd + forecasted)
|
|
102
|
+
end
|
|
103
|
+
h[p] = res
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private_class_method :simulate_backlog, :days_to_last_scheduled_event, :build_summary,
|
|
108
|
+
:ensure_data_up_to_today, :build_dates, :build_arrivals, :build_forecast_map,
|
|
109
|
+
:slice_series
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PredictabilityEngine
|
|
4
|
+
module Calculators
|
|
5
|
+
class CycleTime
|
|
6
|
+
def self.distribution(work_items)
|
|
7
|
+
completed = work_items.select(&:completed?)
|
|
8
|
+
return [] if completed.empty?
|
|
9
|
+
|
|
10
|
+
completed.map(&:cycle_time).sort
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.percentile(work_items, percentile_value)
|
|
14
|
+
dist = distribution(work_items)
|
|
15
|
+
return nil if dist.empty?
|
|
16
|
+
|
|
17
|
+
index = (dist.size * percentile_value / 100.0).ceil - 1
|
|
18
|
+
dist[index]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.completed_sorted(work_items)
|
|
22
|
+
PredictabilityEngine.completed_items(work_items).sort_by(&:end_date)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PredictabilityEngine
|
|
4
|
+
module Calculators
|
|
5
|
+
class Throughput
|
|
6
|
+
def self.daily(work_items, start_date: nil, end_date: nil)
|
|
7
|
+
completed = work_items.select(&:completed?)
|
|
8
|
+
return {} if completed.empty?
|
|
9
|
+
|
|
10
|
+
start_date ||= completed.map(&:end_date).min
|
|
11
|
+
end_date ||= completed.map(&:end_date).max
|
|
12
|
+
|
|
13
|
+
counts = initial_daily_counts(start_date, end_date)
|
|
14
|
+
populate_daily_counts(counts, completed, start_date, end_date)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.initial_daily_counts(start_date, end_date)
|
|
18
|
+
(start_date..end_date).to_h { |d| [d, 0] }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.populate_daily_counts(counts, completed, start_date, end_date)
|
|
22
|
+
completed.each do |item|
|
|
23
|
+
next if item.end_date < start_date || item.end_date > end_date
|
|
24
|
+
|
|
25
|
+
counts[item.end_date] += 1
|
|
26
|
+
end
|
|
27
|
+
counts
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.average(work_items, start_date: nil, end_date: nil)
|
|
31
|
+
counts = daily(work_items, start_date: start_date, end_date: end_date).values
|
|
32
|
+
return 0 if counts.empty?
|
|
33
|
+
|
|
34
|
+
counts.sum.to_f / counts.size
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.histogram_data(work_items)
|
|
38
|
+
daily(work_items).values.tally.sort
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
require 'tty-table'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require_relative 'visualizer'
|
|
7
|
+
require_relative 'summary_visualizer'
|
|
8
|
+
|
|
9
|
+
module PredictabilityEngine
|
|
10
|
+
# Shared Thor configuration for Cli and Viz: common class_options plus
|
|
11
|
+
# auto-wiring of PredictabilityEngine.setup_logging from --log-level/--log-file.
|
|
12
|
+
module CliBase
|
|
13
|
+
VALID_SIZES = Report::Constants::RESOLUTION_CONFIG.keys.freeze
|
|
14
|
+
SIZE_DESC = "Image size for PNG/PPT reports (#{VALID_SIZES.join(', ')})".freeze
|
|
15
|
+
|
|
16
|
+
def self.included(base)
|
|
17
|
+
base.extend(ClassMethods)
|
|
18
|
+
base.class_option :output_dir, type: :string, desc: 'Output directory for reports'
|
|
19
|
+
base.class_option :size, type: :string, default: Report::Constants::DEFAULT_SIZE,
|
|
20
|
+
enum: VALID_SIZES, desc: SIZE_DESC
|
|
21
|
+
base.class_option :log_level, type: :string, default: 'info',
|
|
22
|
+
desc: 'Logging level (debug, info, warn, error)'
|
|
23
|
+
base.class_option :log_file, type: :string, desc: 'Log file path'
|
|
24
|
+
base.class_option :url_prefix, type: :string,
|
|
25
|
+
desc: 'URL prefix for constructing item URLs from IDs (e.g. https://jira.example.com/browse/)'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
module ClassMethods
|
|
29
|
+
def exit_on_failure?
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(*args)
|
|
35
|
+
super
|
|
36
|
+
PredictabilityEngine.setup_logging(level: options[:log_level], log_file: options[:log_file])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class Viz < Thor
|
|
41
|
+
include CliBase
|
|
42
|
+
|
|
43
|
+
class_option :color, type: :boolean, default: true, desc: 'Enable/disable color output for terminal charts'
|
|
44
|
+
|
|
45
|
+
{ scatter: [:cycle_time_scatter, 'Show Cycle Time scatter plot'],
|
|
46
|
+
throughput: [:throughput_histogram, 'Show Throughput histogram'],
|
|
47
|
+
cfd: [:cfd_plot, 'Show Cumulative Flow Diagram'],
|
|
48
|
+
aging_wip: [:aging_wip, 'Show Aging Work In Progress'],
|
|
49
|
+
forecasted_cfd: [:forecasted_cfd_plot,
|
|
50
|
+
'Show Forecasted Cumulative Flow Diagram'] }.each do |cmd, (viz_method, description)|
|
|
51
|
+
desc "#{cmd} SOURCE", description
|
|
52
|
+
define_method(cmd) do |source|
|
|
53
|
+
items = PredictabilityEngine.load_items(source)
|
|
54
|
+
PredictabilityEngine.logger.info Visualizer.send(viz_method, items, color: options[:color])
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
desc 'html_scatter SOURCE [OUTPUT]', 'Generate Vega-Lite HTML scatter plot'
|
|
59
|
+
def html_scatter(source, output = nil)
|
|
60
|
+
generate_html_chart(source, output, 'scatter') do |items|
|
|
61
|
+
Visualizer.vega_cycle_time_scatter(items)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
desc 'html_throughput SOURCE [OUTPUT]', 'Generate Vega-Lite HTML throughput histogram'
|
|
66
|
+
def html_throughput(source, output = nil)
|
|
67
|
+
generate_html_chart(source, output, 'throughput') do |items|
|
|
68
|
+
Visualizer.vega_throughput_histogram(items)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
desc 'html_cfd SOURCE [OUTPUT]', 'Generate Vega-Lite HTML CFD'
|
|
73
|
+
method_option :historical_cfd_history, type: :string,
|
|
74
|
+
desc: 'Historical CFD window (e.g. 1w, 2m, 30d; default: full range)'
|
|
75
|
+
def html_cfd(source, output = nil)
|
|
76
|
+
generate_html_chart(source, output, 'cfd') do |items|
|
|
77
|
+
Visualizer.vega_cfd(items, history_range: options[:historical_cfd_history])
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
desc 'html_forecasted_cfd SOURCE [OUTPUT]', 'Generate Vega-Lite HTML Forecasted CFD'
|
|
82
|
+
method_option :forecast_history, type: :string,
|
|
83
|
+
desc: 'Forecasted CFD history window (e.g. 1w, 2m, 30d; default: 2m)'
|
|
84
|
+
def html_forecasted_cfd(source, output = nil)
|
|
85
|
+
generate_html_chart(source, output, 'forecasted_cfd') do |items|
|
|
86
|
+
Visualizer.vega_forecasted_cfd(items, history_range: options[:forecast_history])
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
desc 'html_aging_wip SOURCE [OUTPUT]', 'Generate Vega-Lite HTML Aging WIP'
|
|
91
|
+
def html_aging_wip(source, output = nil)
|
|
92
|
+
generate_html_chart(source, output, 'aging_wip') do |items|
|
|
93
|
+
Visualizer.vega_aging_wip(items)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
desc 'all SOURCE', 'Show all terminal summary and visualizations'
|
|
98
|
+
def all(source)
|
|
99
|
+
run_and_print_report(source, :terminal)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
desc 'html_all SOURCE [OUTPUT]', 'Generate a combined HTML dashboard (landscape)'
|
|
103
|
+
def html_all(source, output = nil)
|
|
104
|
+
run_and_print_report(source, :html, output: output)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
desc 'landscape SOURCE [OUTPUT]', 'Alias for html_all'
|
|
108
|
+
def landscape(source, output = nil)
|
|
109
|
+
run_and_print_report(source, :landscape, output: output)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
desc 'dashboard SOURCE [OUTPUT]', 'Alias for landscape'
|
|
113
|
+
def dashboard(source, output = nil)
|
|
114
|
+
landscape(source, output)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
desc 'all_html SOURCE [OUTPUT]', 'Alias for html_all'
|
|
118
|
+
def all_html(source, output = nil)
|
|
119
|
+
html_all(source, output)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
desc 'pdf SOURCE [OUTPUT]', 'Generate a PDF report'
|
|
123
|
+
def pdf(source, output = nil)
|
|
124
|
+
run_and_print_report(source, :pdf, output: output)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
desc 'a3_landscape SOURCE [OUTPUT]', 'Generate an A3 landscape PDF dashboard'
|
|
128
|
+
def a3_landscape(source, output = nil)
|
|
129
|
+
run_and_print_report(source, :a3_landscape, output: output)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
desc 'markdown SOURCE [OUTPUT]', 'Generate a Markdown report'
|
|
133
|
+
def markdown(source, output = nil)
|
|
134
|
+
run_and_print_report(source, :markdown, output: output)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
desc 'md SOURCE [OUTPUT]', 'Alias for markdown'
|
|
138
|
+
def md(source, output = nil)
|
|
139
|
+
markdown(source, output)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
desc 'confluence SOURCE [OUTPUT]', 'Generate a Confluence markup report'
|
|
143
|
+
def confluence(source, output = nil)
|
|
144
|
+
run_and_print_report(source, :confluence, output: output)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
desc 'conf SOURCE [OUTPUT]', 'Alias for confluence'
|
|
148
|
+
def conf(source, output = nil)
|
|
149
|
+
confluence(source, output)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
desc 'png SOURCE [OUTPUT]', 'Generate a PNG report'
|
|
153
|
+
def png(source, output = nil)
|
|
154
|
+
run_and_print_report(source, :png, output: output)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
desc 'all_formats SOURCE', 'Generate all report formats at once'
|
|
158
|
+
def all_formats(source)
|
|
159
|
+
ReportGenerator.clean_report_dir(source, **options)
|
|
160
|
+
items = PredictabilityEngine.load_items(source, url_prefix: options[:url_prefix])
|
|
161
|
+
reports = Report.generate_all(items)
|
|
162
|
+
%i[terminal html pdf png md conf ppt raw_csv xlsx].each do |fmt|
|
|
163
|
+
PredictabilityEngine.run_and_print_report(source, fmt, options, items: items, reports: reports)
|
|
164
|
+
rescue StandardError => e
|
|
165
|
+
PredictabilityEngine.logger.warn { "Failed to generate #{fmt} report: #{e.message}" }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def run_and_print_report(source, format, output: nil)
|
|
172
|
+
PredictabilityEngine.run_and_print_report(source, format, options, output: output)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def generate_html_chart(source, output, type)
|
|
176
|
+
items = PredictabilityEngine.load_items(source)
|
|
177
|
+
path = generate_output_path(source, output, "#{type}.html")
|
|
178
|
+
PredictabilityEngine.write_file(path, Visualizer.to_full_html(yield(items), items))
|
|
179
|
+
PredictabilityEngine.logger.info { "Chart generated at #{path}" }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def generate_output_path(source, output, filename)
|
|
183
|
+
return output if output
|
|
184
|
+
|
|
185
|
+
base = File.basename(source, '.*')
|
|
186
|
+
dir = if options[:output_dir]
|
|
187
|
+
File.join(options[:output_dir], base)
|
|
188
|
+
else
|
|
189
|
+
File.join(File.dirname(source), 'reports', base)
|
|
190
|
+
end
|
|
191
|
+
File.join(dir, filename)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
class Cli < Thor
|
|
196
|
+
include CliBase
|
|
197
|
+
include JiraConfigPrompter
|
|
198
|
+
|
|
199
|
+
desc 'viz SUBCOMMAND ...ARGS', 'Visualization commands'
|
|
200
|
+
subcommand 'viz', Viz
|
|
201
|
+
desc 'summary SOURCE', 'Load data from SOURCE and show flow metrics summary'
|
|
202
|
+
method_option :color, type: :boolean, default: true, desc: 'Enable/disable color output'
|
|
203
|
+
def summary(source)
|
|
204
|
+
items = PredictabilityEngine.load_items(source)
|
|
205
|
+
PredictabilityEngine.logger.info { SummaryVisualizer.metrics_terminal(items, color: options[:color]) }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
desc 'report SOURCE FORMAT [OUTPUT]', 'Generate a full report in various formats (terminal, html, pdf, md, conf)'
|
|
209
|
+
method_option :color, type: :boolean, default: true, desc: 'Enable/disable color output'
|
|
210
|
+
method_option :clean, type: :boolean, default: true, desc: 'Clean the report directory before generation'
|
|
211
|
+
def report(input_source, format = 'terminal', output = nil)
|
|
212
|
+
if format.to_sym != :terminal && output.nil? && options[:clean]
|
|
213
|
+
ReportGenerator.clean_report_dir(input_source, **options)
|
|
214
|
+
end
|
|
215
|
+
PredictabilityEngine.run_and_print_report(input_source, format, options, output: output)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
desc 'batch SOURCE', 'Run all report formats for the given SOURCE'
|
|
219
|
+
method_option :color, type: :boolean, default: true, desc: 'Enable/disable color output'
|
|
220
|
+
def batch(source)
|
|
221
|
+
Viz.new([], options).all_formats(source)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
desc 'setup', 'Install/update all dependencies (Ruby gems + Node.js + Playwright + Chromium)'
|
|
225
|
+
def setup
|
|
226
|
+
SetupManager.new.run
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
desc 'init FILENAME', 'Create a template YAML file for JIRA source'
|
|
230
|
+
def init(filename)
|
|
231
|
+
filename += '.yml' unless filename.end_with?('.yml', '.yaml')
|
|
232
|
+
content = <<~YAML
|
|
233
|
+
# JIRA Data Source Configuration
|
|
234
|
+
# jira_profile: prod-instance # Optional: profile name from ~/.config/jira/jira_credentials.yml
|
|
235
|
+
# project: MYPROJ # Optional: JIRA Project Key
|
|
236
|
+
# filter_id: "12345" # Optional: JIRA Filter ID
|
|
237
|
+
# filter_name: "My Filter" # Optional: JIRA Filter Name
|
|
238
|
+
# query: "project = PROJ" # Optional: Direct JQL query
|
|
239
|
+
YAML
|
|
240
|
+
File.write(filename, content)
|
|
241
|
+
PredictabilityEngine.logger.info { "Template created at #{filename}" }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
desc 'jira_config PROFILE', 'Generate/Update JIRA credentials in ~/.config/jira/jira_credentials.yml'
|
|
245
|
+
method_option :auth_mode, aliases: '-a', default: 'basic',
|
|
246
|
+
desc: 'Auth mode: basic | bearer | cookie | mfa_api | mfa_browser'
|
|
247
|
+
def jira_config(profile)
|
|
248
|
+
site = ask('Jira site (e.g., https://your-domain.atlassian.net):')
|
|
249
|
+
context_path = ask('Context path, if any (e.g., /jira — leave blank for Atlassian Cloud):')
|
|
250
|
+
mode = options[:auth_mode]
|
|
251
|
+
|
|
252
|
+
profile_data = build_profile_data(site, context_path, mode)
|
|
253
|
+
|
|
254
|
+
path = Config.jira_credentials_file
|
|
255
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
256
|
+
|
|
257
|
+
config = File.exist?(path) ? Config.load_yaml_file(path) : {}
|
|
258
|
+
config ||= {}
|
|
259
|
+
config['profiles'] ||= {}
|
|
260
|
+
config['profiles'][profile] = profile_data
|
|
261
|
+
|
|
262
|
+
File.write(path, config.to_yaml)
|
|
263
|
+
PredictabilityEngine.logger.info { "Jira credentials for profile '#{profile}' saved to #{path}" }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
desc 'jira_workflow PROFILE [OUTPUT]',
|
|
267
|
+
'Extract Jira workflow statuses for PROFILE into an editable YAML mapping ' \
|
|
268
|
+
'(default: ~/.config/jira/<profile>.workflow.yml). Re-running refreshes the ' \
|
|
269
|
+
'snapshot while preserving any roles you already set.'
|
|
270
|
+
def jira_workflow(profile, output = nil)
|
|
271
|
+
path = output || JiraWorkflow.default_path(profile)
|
|
272
|
+
fresh = JiraWorkflow.extract(profile)
|
|
273
|
+
workflow = File.exist?(path) ? JiraWorkflow.load(path).refresh(fresh) : fresh
|
|
274
|
+
workflow.write(path)
|
|
275
|
+
action = File.exist?(path) ? 'refreshed' : 'written'
|
|
276
|
+
PredictabilityEngine.logger.info { "Workflow for profile '#{profile}' #{action}: #{path}" }
|
|
277
|
+
PredictabilityEngine.logger.info { "Review #{path} and set role: arrival / departure / null per status." }
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
desc 'jira_workflow_merge OUTPUT SOURCE1 [SOURCE2 ...]',
|
|
281
|
+
'Merge multiple workflow configs into a shared config. Each SOURCE is ' \
|
|
282
|
+
'either a profile name (resolved to ~/.config/jira/<profile>.workflow.yml) ' \
|
|
283
|
+
'or an explicit path to a workflow YAML file.'
|
|
284
|
+
def jira_workflow_merge(output, *sources)
|
|
285
|
+
raise Error, 'Need at least one workflow source to merge' if sources.empty?
|
|
286
|
+
|
|
287
|
+
configs = sources.map do |src|
|
|
288
|
+
path = File.exist?(src) ? src : JiraWorkflow.default_path(src)
|
|
289
|
+
JiraWorkflow.load(path) || raise(Error, "Workflow not found for '#{src}' (tried #{path})")
|
|
290
|
+
end
|
|
291
|
+
JiraWorkflow.merge(configs).write(output)
|
|
292
|
+
PredictabilityEngine.logger.info { "Merged workflow from #{sources.join(', ')} written to #{output}" }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
desc 'forecast SOURCE BACKLOG_COUNT', 'Run Monte Carlo simulation for BACKLOG_COUNT items'
|
|
296
|
+
def forecast(source, backlog_count)
|
|
297
|
+
items = PredictabilityEngine.load_items(source)
|
|
298
|
+
|
|
299
|
+
historical = Calculators::Throughput.daily(items).values
|
|
300
|
+
results = Simulators::MonteCarlo.when_will_it_be_done(backlog_count.to_i, historical)
|
|
301
|
+
|
|
302
|
+
print_forecast_results(backlog_count, results)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
desc 'calibrate SOURCE', 'Validate Monte Carlo simulation accuracy via hindcast calibration'
|
|
306
|
+
method_option :validation_trials, type: :numeric, default: 200,
|
|
307
|
+
desc: 'Number of historical as-of dates to sample'
|
|
308
|
+
method_option :primary_trials, type: :numeric, default: 10_000,
|
|
309
|
+
desc: 'Monte Carlo trials per hindcast point'
|
|
310
|
+
def calibrate(source)
|
|
311
|
+
items = PredictabilityEngine.load_items(source)
|
|
312
|
+
result = Simulators::MonteCarloValidator.calibration(
|
|
313
|
+
items,
|
|
314
|
+
validation_trials: options[:validation_trials],
|
|
315
|
+
primary_trials: options[:primary_trials]
|
|
316
|
+
)
|
|
317
|
+
if result.nil?
|
|
318
|
+
PredictabilityEngine.logger.info do
|
|
319
|
+
'Insufficient data for hindcast calibration (need 10+ completed items with historical WIP).'
|
|
320
|
+
end
|
|
321
|
+
return
|
|
322
|
+
end
|
|
323
|
+
print_calibration_results(result)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
GENERATE_SIZE_DESC = "Preset volume: #{DataGenerator::PRESETS.map do |n, c|
|
|
327
|
+
"#{n} (#{c[:completed]}/#{c[:wip]})"
|
|
328
|
+
end.join(', ')}".freeze
|
|
329
|
+
|
|
330
|
+
desc 'generate OUTPUT', 'Generate a synthetic sample CSV for smoke tests and demos'
|
|
331
|
+
method_option :size, type: :string, default: 'medium',
|
|
332
|
+
enum: DataGenerator::PRESETS.keys.map(&:to_s),
|
|
333
|
+
desc: GENERATE_SIZE_DESC
|
|
334
|
+
method_option :completed, type: :numeric, desc: 'Override number of completed items'
|
|
335
|
+
method_option :wip, type: :numeric, desc: 'Override number of WIP items'
|
|
336
|
+
def generate(output)
|
|
337
|
+
path = DataGenerator.generate(
|
|
338
|
+
output: output,
|
|
339
|
+
size: options[:size].to_sym,
|
|
340
|
+
completed: options[:completed],
|
|
341
|
+
wip: options[:wip]
|
|
342
|
+
)
|
|
343
|
+
PredictabilityEngine.logger.info { "Synthetic #{options[:size]} dataset written to #{path}" }
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
private
|
|
347
|
+
|
|
348
|
+
def ask_secret(prompt)
|
|
349
|
+
if $stdin.isatty
|
|
350
|
+
result = ask(prompt, echo: false)
|
|
351
|
+
puts ''
|
|
352
|
+
result
|
|
353
|
+
else
|
|
354
|
+
ask(prompt)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def print_calibration_results(result)
|
|
359
|
+
PredictabilityEngine.logger.info { 'Monte Carlo Hindcast Calibration' }
|
|
360
|
+
PredictabilityEngine.logger.info { '---------------------------------' }
|
|
361
|
+
PredictabilityEngine.logger.info { "Trials run: #{result[:trials_run]} / Skipped: #{result[:trials_skipped]}" }
|
|
362
|
+
PredictabilityEngine.logger.info { '' }
|
|
363
|
+
PredictabilityEngine::DEFAULT_PERCENTILES.each do |p|
|
|
364
|
+
coverage = result[p]
|
|
365
|
+
next unless coverage
|
|
366
|
+
|
|
367
|
+
actual_pct = (coverage * 100).round(1)
|
|
368
|
+
delta = (coverage - (p / 100.0)) * 100
|
|
369
|
+
label = if delta > 2 then 'conservative'
|
|
370
|
+
elsif delta < -2 then 'optimistic'
|
|
371
|
+
else 'well-calibrated'
|
|
372
|
+
end
|
|
373
|
+
PredictabilityEngine.logger.info { " p#{p}: #{actual_pct}% actual coverage (#{label})" }
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def print_forecast_results(backlog_count, results)
|
|
378
|
+
PredictabilityEngine.logger.info { 'Monte Carlo Simulation Results (When will it be done?)' }
|
|
379
|
+
PredictabilityEngine.logger.info { '------------------------------------------------------' }
|
|
380
|
+
PredictabilityEngine.logger.info { "Backlog size: #{backlog_count}" }
|
|
381
|
+
PredictabilityEngine.logger.info { "Number of trials: #{Simulators::MonteCarlo::DEFAULT_TRIALS}" }
|
|
382
|
+
PredictabilityEngine.logger.info { '' }
|
|
383
|
+
PredictabilityEngine.logger.info { 'Results:' }
|
|
384
|
+
PredictabilityEngine::DEFAULT_PERCENTILES.each do |p|
|
|
385
|
+
val = Simulators::MonteCarlo.percentile(results, p)
|
|
386
|
+
PredictabilityEngine.logger.info { " #{p}% confidence: Done in #{val} days" }
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
public
|
|
391
|
+
|
|
392
|
+
desc 'ask_ai SOURCE QUESTION', 'Ask the AI assistant about the data in SOURCE'
|
|
393
|
+
def ask_ai(source, question)
|
|
394
|
+
# Assistant needs the manager or at least items.
|
|
395
|
+
manager = DataManager.new
|
|
396
|
+
manager.load(source)
|
|
397
|
+
|
|
398
|
+
assistant = Agents::Assistant.new(manager)
|
|
399
|
+
PredictabilityEngine.logger.info { 'AI Thinking...' }
|
|
400
|
+
response = assistant.ask(question)
|
|
401
|
+
|
|
402
|
+
# response is an array of messages or similar depending on langchain version
|
|
403
|
+
# In recent langchainrb versions assistant.run returns the last message
|
|
404
|
+
PredictabilityEngine.logger.info { 'AI Response:' }
|
|
405
|
+
PredictabilityEngine.logger.info { '------------' }
|
|
406
|
+
# Assuming response is a message object with .content
|
|
407
|
+
if response.respond_to?(:content)
|
|
408
|
+
PredictabilityEngine.logger.info { response.content }
|
|
409
|
+
else
|
|
410
|
+
PredictabilityEngine.logger.info { response }
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|