coplan-engine 0.1.0

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 (84) hide show
  1. checksums.yaml +7 -0
  2. data/app/assets/stylesheets/coplan/application.css +956 -0
  3. data/app/controllers/coplan/api/v1/base_controller.rb +75 -0
  4. data/app/controllers/coplan/api/v1/comments_controller.rb +135 -0
  5. data/app/controllers/coplan/api/v1/leases_controller.rb +62 -0
  6. data/app/controllers/coplan/api/v1/operations_controller.rb +298 -0
  7. data/app/controllers/coplan/api/v1/plans_controller.rb +129 -0
  8. data/app/controllers/coplan/api/v1/sessions_controller.rb +92 -0
  9. data/app/controllers/coplan/application_controller.rb +78 -0
  10. data/app/controllers/coplan/automated_reviews_controller.rb +29 -0
  11. data/app/controllers/coplan/comment_threads_controller.rb +148 -0
  12. data/app/controllers/coplan/comments_controller.rb +40 -0
  13. data/app/controllers/coplan/dashboard_controller.rb +6 -0
  14. data/app/controllers/coplan/plan_versions_controller.rb +29 -0
  15. data/app/controllers/coplan/plans_controller.rb +53 -0
  16. data/app/controllers/coplan/settings/tokens_controller.rb +37 -0
  17. data/app/helpers/coplan/application_helper.rb +4 -0
  18. data/app/helpers/coplan/comments_helper.rb +20 -0
  19. data/app/helpers/coplan/markdown_helper.rb +36 -0
  20. data/app/javascript/controllers/coplan/dropdown_controller.js +25 -0
  21. data/app/javascript/controllers/coplan/line_selection_controller.js +112 -0
  22. data/app/javascript/controllers/coplan/tabs_controller.js +18 -0
  23. data/app/javascript/controllers/coplan/text_selection_controller.js +376 -0
  24. data/app/jobs/coplan/application_job.rb +4 -0
  25. data/app/jobs/coplan/automated_review_job.rb +71 -0
  26. data/app/jobs/coplan/commit_expired_session_job.rb +29 -0
  27. data/app/jobs/coplan/notification_job.rb +10 -0
  28. data/app/models/coplan/api_token.rb +50 -0
  29. data/app/models/coplan/application_record.rb +14 -0
  30. data/app/models/coplan/automated_plan_reviewer.rb +62 -0
  31. data/app/models/coplan/comment.rb +24 -0
  32. data/app/models/coplan/comment_thread.rb +151 -0
  33. data/app/models/coplan/current.rb +5 -0
  34. data/app/models/coplan/edit_lease.rb +57 -0
  35. data/app/models/coplan/edit_session.rb +61 -0
  36. data/app/models/coplan/plan.rb +28 -0
  37. data/app/models/coplan/plan_collaborator.rb +12 -0
  38. data/app/models/coplan/plan_version.rb +23 -0
  39. data/app/models/coplan/user.rb +20 -0
  40. data/app/policies/coplan/application_policy.rb +14 -0
  41. data/app/policies/coplan/comment_thread_policy.rb +23 -0
  42. data/app/policies/coplan/plan_policy.rb +15 -0
  43. data/app/services/coplan/ai_providers/anthropic.rb +21 -0
  44. data/app/services/coplan/ai_providers/open_ai.rb +44 -0
  45. data/app/services/coplan/broadcaster.rb +32 -0
  46. data/app/services/coplan/plans/apply_operations.rb +128 -0
  47. data/app/services/coplan/plans/commit_session.rb +186 -0
  48. data/app/services/coplan/plans/create.rb +30 -0
  49. data/app/services/coplan/plans/operation_error.rb +5 -0
  50. data/app/services/coplan/plans/position_resolver.rb +191 -0
  51. data/app/services/coplan/plans/review_prompt_formatter.rb +25 -0
  52. data/app/services/coplan/plans/review_response_parser.rb +65 -0
  53. data/app/services/coplan/plans/transform_range.rb +99 -0
  54. data/app/services/coplan/plans/trigger_automated_reviews.rb +33 -0
  55. data/app/views/coplan/comment_threads/_new_comment_form.html.erb +17 -0
  56. data/app/views/coplan/comment_threads/_reply_form.html.erb +8 -0
  57. data/app/views/coplan/comment_threads/_thread.html.erb +47 -0
  58. data/app/views/coplan/comments/_comment.html.erb +9 -0
  59. data/app/views/coplan/dashboard/show.html.erb +8 -0
  60. data/app/views/coplan/plan_versions/index.html.erb +21 -0
  61. data/app/views/coplan/plan_versions/show.html.erb +29 -0
  62. data/app/views/coplan/plans/_header.html.erb +26 -0
  63. data/app/views/coplan/plans/edit.html.erb +15 -0
  64. data/app/views/coplan/plans/index.html.erb +35 -0
  65. data/app/views/coplan/plans/show.html.erb +56 -0
  66. data/app/views/coplan/settings/tokens/_form.html.erb +8 -0
  67. data/app/views/coplan/settings/tokens/_token_reveal.html.erb +7 -0
  68. data/app/views/coplan/settings/tokens/_token_row.html.erb +24 -0
  69. data/app/views/coplan/settings/tokens/create.turbo_stream.erb +11 -0
  70. data/app/views/coplan/settings/tokens/destroy.turbo_stream.erb +5 -0
  71. data/app/views/coplan/settings/tokens/index.html.erb +32 -0
  72. data/app/views/layouts/coplan/application.html.erb +42 -0
  73. data/config/importmap.rb +1 -0
  74. data/config/routes.rb +41 -0
  75. data/db/migrate/20260226200000_create_coplan_schema.rb +173 -0
  76. data/lib/coplan/configuration.rb +17 -0
  77. data/lib/coplan/engine.rb +34 -0
  78. data/lib/coplan/version.rb +3 -0
  79. data/lib/coplan.rb +15 -0
  80. data/lib/tasks/coplan.rake +4 -0
  81. data/prompts/reviewers/routing.md +14 -0
  82. data/prompts/reviewers/scalability.md +14 -0
  83. data/prompts/reviewers/security.md +14 -0
  84. metadata +245 -0
@@ -0,0 +1,62 @@
1
+ module CoPlan
2
+ class AutomatedPlanReviewer < ApplicationRecord
3
+ ACTOR_TYPE = "cloud_persona"
4
+ AI_PROVIDERS = %w[openai anthropic].freeze
5
+
6
+ DEFAULT_REVIEWERS = [
7
+ { key: "security-reviewer", name: "Security Reviewer", prompt_file: "prompts/reviewers/security.md",
8
+ trigger_statuses: [ "considering" ], ai_model: "gpt-4o" },
9
+ { key: "scalability-reviewer", name: "Scalability Reviewer", prompt_file: "prompts/reviewers/scalability.md",
10
+ trigger_statuses: [ "considering", "developing" ], ai_model: "gpt-4o" },
11
+ { key: "routing-reviewer", name: "Routing Reviewer", prompt_file: "prompts/reviewers/routing.md",
12
+ trigger_statuses: [], ai_model: "gpt-4o" }
13
+ ].freeze
14
+
15
+ after_initialize { self.trigger_statuses ||= [] }
16
+
17
+ validates :key, presence: true,
18
+ format: { with: /\A[a-z0-9-]+\z/, message: "only allows lowercase letters, numbers, and hyphens" }
19
+ validates :key, uniqueness: true
20
+ validates :name, presence: true
21
+ validates :prompt_text, presence: true
22
+ validates :ai_provider, presence: true, inclusion: { in: AI_PROVIDERS }
23
+ validates :ai_model, presence: true
24
+ validate :validate_trigger_statuses
25
+
26
+ scope :enabled, -> { where(enabled: true) }
27
+
28
+ def self.create_defaults
29
+ DEFAULT_REVIEWERS.each do |template|
30
+ find_or_create_by!(key: template[:key]) do |r|
31
+ r.name = template[:name]
32
+ r.prompt_text = File.read(CoPlan::Engine.root.join(template[:prompt_file]))
33
+ r.trigger_statuses = template[:trigger_statuses]
34
+ r.ai_model = template[:ai_model]
35
+ end
36
+ end
37
+ end
38
+
39
+ def self.ransackable_attributes(auth_object = nil)
40
+ %w[id key name enabled ai_provider ai_model created_at updated_at]
41
+ end
42
+
43
+ def self.ransackable_associations(auth_object = nil)
44
+ %w[]
45
+ end
46
+
47
+ def triggers_on_status?(status)
48
+ trigger_statuses.include?(status.to_s)
49
+ end
50
+
51
+ private
52
+
53
+ def validate_trigger_statuses
54
+ return if trigger_statuses.blank?
55
+
56
+ invalid = trigger_statuses - Plan::STATUSES
57
+ if invalid.any?
58
+ errors.add(:trigger_statuses, "contains invalid status: #{invalid.join(', ')}. Valid statuses are: #{Plan::STATUSES.join(', ')}")
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,24 @@
1
+ module CoPlan
2
+ class Comment < ApplicationRecord
3
+ AUTHOR_TYPES = %w[human local_agent cloud_persona system].freeze
4
+
5
+ belongs_to :comment_thread
6
+
7
+ validates :body_markdown, presence: true
8
+ validates :author_type, presence: true, inclusion: { in: AUTHOR_TYPES }
9
+ validates :agent_name, presence: { message: "is required for agent comments" }, if: -> { author_type == "local_agent" }
10
+ validates :agent_name, length: { maximum: 20 }, allow_nil: true
11
+
12
+ after_create_commit :notify_plan_author, if: :first_comment_in_thread?
13
+
14
+ private
15
+
16
+ def first_comment_in_thread?
17
+ self == comment_thread.comments.order(:created_at).first
18
+ end
19
+
20
+ def notify_plan_author
21
+ CoPlan::NotificationJob.perform_later("comment_created", { comment_thread_id: comment_thread_id })
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,151 @@
1
+ module CoPlan
2
+ class CommentThread < ApplicationRecord
3
+ STATUSES = %w[open resolved accepted dismissed].freeze
4
+
5
+ attr_accessor :anchor_occurrence
6
+
7
+ belongs_to :plan
8
+ belongs_to :plan_version
9
+ belongs_to :created_by_user, class_name: "CoPlan::User"
10
+ belongs_to :resolved_by_user, class_name: "CoPlan::User", optional: true
11
+ belongs_to :out_of_date_since_version, class_name: "PlanVersion", optional: true
12
+ belongs_to :addressed_in_plan_version, class_name: "PlanVersion", optional: true
13
+ has_many :comments, dependent: :destroy
14
+
15
+ validates :status, presence: true, inclusion: { in: STATUSES }
16
+
17
+ before_create :resolve_anchor_position
18
+
19
+ scope :open_threads, -> { where(status: "open") }
20
+ scope :current, -> { where(out_of_date: false) }
21
+ scope :active, -> { where(status: "open", out_of_date: false) }
22
+ scope :archived, -> { where("status != 'open' OR out_of_date = ?", true) }
23
+
24
+ # Transforms anchor positions through intervening version edits using OT.
25
+ # Threads without positional data (anchor_start/anchor_end/anchor_revision)
26
+ # are marked out-of-date unconditionally — all new threads resolve positions
27
+ # on creation via resolve_anchor_position.
28
+ def self.mark_out_of_date_for_new_version!(new_version)
29
+ threads = where(out_of_date: false).where.not(plan_version_id: new_version.id)
30
+ anchored_threads = threads.select(&:anchored?)
31
+
32
+ # Pre-fetch all versions that any thread might need (from the oldest
33
+ # anchor_revision to the new version) in a single query.
34
+ min_anchor_rev = anchored_threads
35
+ .filter_map { |t| t.anchor_revision if t.anchor_start.present? && t.anchor_end.present? && t.anchor_revision.present? }
36
+ .min
37
+
38
+ all_versions = if min_anchor_rev
39
+ new_version.plan.plan_versions
40
+ .where("revision > ? AND revision <= ?", min_anchor_rev, new_version.revision)
41
+ .order(revision: :asc)
42
+ .to_a
43
+ else
44
+ []
45
+ end
46
+
47
+ anchored_threads.each do |thread|
48
+ unless thread.anchor_start.present? && thread.anchor_end.present? && thread.anchor_revision.present?
49
+ thread.update_columns(out_of_date: true, out_of_date_since_version_id: new_version.id)
50
+ next
51
+ end
52
+
53
+ intervening = all_versions.select { |v| v.revision > thread.anchor_revision && v.revision <= new_version.revision }
54
+
55
+ begin
56
+ new_range = Plans::TransformRange.transform_through_versions(
57
+ [thread.anchor_start, thread.anchor_end],
58
+ intervening
59
+ )
60
+ thread.update_columns(
61
+ anchor_start: new_range[0],
62
+ anchor_end: new_range[1],
63
+ anchor_revision: new_version.revision
64
+ )
65
+ rescue Plans::TransformRange::Conflict
66
+ thread.update_columns(
67
+ out_of_date: true,
68
+ out_of_date_since_version_id: new_version.id
69
+ )
70
+ end
71
+ end
72
+ end
73
+
74
+ def anchored?
75
+ anchor_text.present?
76
+ end
77
+
78
+ def line_specific?
79
+ start_line.present?
80
+ end
81
+
82
+ def line_range_text
83
+ return nil unless line_specific?
84
+ start_line == end_line ? "Line #{start_line}" : "Lines #{start_line}–#{end_line}"
85
+ end
86
+
87
+ def anchor_preview(max_length: 80)
88
+ return nil unless anchored?
89
+ anchor_text.length > max_length ? "#{anchor_text[0...max_length]}…" : anchor_text
90
+ end
91
+
92
+ def resolve!(user)
93
+ update!(status: "resolved", resolved_by_user: user)
94
+ end
95
+
96
+ def accept!(user)
97
+ update!(status: "accepted", resolved_by_user: user)
98
+ end
99
+
100
+ def dismiss!(user)
101
+ update!(status: "dismissed", resolved_by_user: user)
102
+ end
103
+
104
+ def anchor_valid?
105
+ return true unless anchored?
106
+ !out_of_date
107
+ end
108
+
109
+ def anchor_context_with_highlight(chars: 100)
110
+ return nil unless anchored? && anchor_start.present?
111
+
112
+ content = plan.current_content
113
+ return nil unless content.present?
114
+
115
+ context_start = [anchor_start - chars, 0].max
116
+ context_end = [anchor_end + chars, content.length].min
117
+
118
+ before = content[context_start...anchor_start]
119
+ anchor = content[anchor_start...anchor_end]
120
+ after = content[anchor_end...context_end]
121
+
122
+ "#{before}**#{anchor}**#{after}"
123
+ end
124
+
125
+ private
126
+
127
+ def resolve_anchor_position
128
+ return unless anchor_text.present?
129
+
130
+ content = plan.current_content
131
+ return unless content.present?
132
+
133
+ occurrence = self.anchor_occurrence || 1
134
+ return if occurrence < 1
135
+
136
+ ranges = []
137
+ start_pos = 0
138
+ while (idx = content.index(anchor_text, start_pos))
139
+ ranges << [idx, idx + anchor_text.length]
140
+ start_pos = idx + anchor_text.length
141
+ end
142
+
143
+ if ranges.length >= occurrence
144
+ range = ranges[occurrence - 1]
145
+ self.anchor_start = range[0]
146
+ self.anchor_end = range[1]
147
+ self.anchor_revision = plan.current_revision
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,5 @@
1
+ module CoPlan
2
+ class Current < ActiveSupport::CurrentAttributes
3
+ attribute :user
4
+ end
5
+ end
@@ -0,0 +1,57 @@
1
+ module CoPlan
2
+ class EditLease < ApplicationRecord
3
+ HOLDER_TYPES = %w[local_agent cloud_persona system].freeze
4
+ LEASE_DURATION = 5.minutes
5
+
6
+ class Conflict < StandardError; end
7
+
8
+ belongs_to :plan
9
+
10
+ validates :holder_type, presence: true, inclusion: { in: HOLDER_TYPES }
11
+ validates :lease_token_digest, presence: true
12
+ validates :expires_at, presence: true
13
+ validates :last_heartbeat_at, presence: true
14
+
15
+ def self.acquire!(plan:, holder_type:, holder_id:, lease_token:)
16
+ digest = Digest::SHA256.hexdigest(lease_token)
17
+
18
+ ActiveRecord::Base.transaction do
19
+ lease = EditLease.lock.find_by(plan_id: plan.id)
20
+ if lease && lease.expires_at > Time.current && lease.lease_token_digest != digest
21
+ raise Conflict, "Plan is currently being edited by another agent"
22
+ end
23
+ lease ||= EditLease.new(plan_id: plan.id)
24
+ lease.update!(
25
+ holder_type: holder_type,
26
+ holder_id: holder_id,
27
+ lease_token_digest: digest,
28
+ expires_at: LEASE_DURATION.from_now,
29
+ last_heartbeat_at: Time.current
30
+ )
31
+ lease
32
+ end
33
+ end
34
+
35
+ def renew!(lease_token:)
36
+ digest = Digest::SHA256.hexdigest(lease_token)
37
+ raise Conflict, "Lease token mismatch" unless lease_token_digest == digest
38
+ update!(expires_at: LEASE_DURATION.from_now, last_heartbeat_at: Time.current)
39
+ self
40
+ end
41
+
42
+ def release!(lease_token:)
43
+ digest = Digest::SHA256.hexdigest(lease_token)
44
+ raise Conflict, "Lease token mismatch" unless lease_token_digest == digest
45
+ destroy!
46
+ end
47
+
48
+ def held?
49
+ expires_at > Time.current
50
+ end
51
+
52
+ def held_by?(lease_token:)
53
+ digest = Digest::SHA256.hexdigest(lease_token)
54
+ lease_token_digest == digest && held?
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,61 @@
1
+ module CoPlan
2
+ class EditSession < ApplicationRecord
3
+ STATUSES = %w[open committed expired cancelled failed].freeze
4
+ ACTOR_TYPES = %w[human local_agent cloud_persona].freeze
5
+ LOCAL_AGENT_TTL = 10.minutes
6
+ CLOUD_PERSONA_TTL = 30.minutes
7
+
8
+ belongs_to :plan
9
+ belongs_to :plan_version, optional: true
10
+
11
+ after_initialize { self.operations_json ||= [] }
12
+ after_create :enqueue_expiry_job
13
+
14
+ validates :status, presence: true, inclusion: { in: STATUSES }
15
+ validates :actor_type, presence: true, inclusion: { in: ACTOR_TYPES }
16
+ validates :base_revision, presence: true
17
+ validates :expires_at, presence: true
18
+
19
+ scope :open_sessions, -> { where(status: "open") }
20
+ scope :expired_pending, -> { where(status: "open").where("expires_at < ?", Time.current) }
21
+
22
+ def open?
23
+ status == "open"
24
+ end
25
+
26
+ def active?
27
+ open? && (expires_at.nil? || expires_at > Time.current)
28
+ end
29
+
30
+ def committed?
31
+ status == "committed"
32
+ end
33
+
34
+ def expired?
35
+ open? && expires_at < Time.current
36
+ end
37
+
38
+ def has_operations?
39
+ operations_json.present? && operations_json.any?
40
+ end
41
+
42
+ def add_operation(op)
43
+ self.operations_json = operations_json + [op]
44
+ save!
45
+ end
46
+
47
+ def self.ransackable_attributes(auth_object = nil)
48
+ %w[id status actor_type actor_id plan_id base_revision created_at]
49
+ end
50
+
51
+ def self.ransackable_associations(auth_object = nil)
52
+ %w[plan plan_version]
53
+ end
54
+
55
+ private
56
+
57
+ def enqueue_expiry_job
58
+ CommitExpiredSessionJob.set(wait_until: expires_at).perform_later(session_id: id)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,28 @@
1
+ module CoPlan
2
+ class Plan < ApplicationRecord
3
+ STATUSES = %w[brainstorm considering developing live abandoned].freeze
4
+
5
+ belongs_to :created_by_user, class_name: "CoPlan::User"
6
+ belongs_to :current_plan_version, class_name: "PlanVersion", optional: true
7
+ has_many :plan_versions, -> { order(revision: :asc) }, dependent: :destroy
8
+ has_many :plan_collaborators, dependent: :destroy
9
+ has_many :collaborators, through: :plan_collaborators, source: :user
10
+ has_many :comment_threads, dependent: :destroy
11
+ has_many :edit_sessions, dependent: :destroy
12
+ has_one :edit_lease, dependent: :destroy
13
+
14
+ after_initialize { self.tags ||= [] }
15
+ after_initialize { self.metadata ||= {} }
16
+
17
+ validates :title, presence: true
18
+ validates :status, presence: true, inclusion: { in: STATUSES }
19
+
20
+ def to_param
21
+ id
22
+ end
23
+
24
+ def current_content
25
+ current_plan_version&.content_markdown
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,12 @@
1
+ module CoPlan
2
+ class PlanCollaborator < ApplicationRecord
3
+ ROLES = %w[author reviewer viewer].freeze
4
+
5
+ belongs_to :plan
6
+ belongs_to :user, class_name: "CoPlan::User"
7
+ belongs_to :added_by_user, class_name: "CoPlan::User", optional: true
8
+
9
+ validates :role, presence: true, inclusion: { in: ROLES }
10
+ validates :user_id, uniqueness: { scope: :plan_id }
11
+ end
12
+ end
@@ -0,0 +1,23 @@
1
+ module CoPlan
2
+ class PlanVersion < ApplicationRecord
3
+ ACTOR_TYPES = %w[human local_agent cloud_persona system].freeze
4
+
5
+ belongs_to :plan
6
+ has_many :comment_threads, dependent: :nullify
7
+
8
+ after_initialize { self.operations_json ||= [] }
9
+
10
+ validates :revision, presence: true, uniqueness: { scope: :plan_id }
11
+ validates :content_markdown, presence: true
12
+ validates :content_sha256, presence: true
13
+ validates :actor_type, presence: true, inclusion: { in: ACTOR_TYPES }
14
+
15
+ before_validation :compute_sha256, if: -> { content_markdown.present? && content_sha256.blank? }
16
+
17
+ private
18
+
19
+ def compute_sha256
20
+ self.content_sha256 = Digest::SHA256.hexdigest(content_markdown)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,20 @@
1
+ module CoPlan
2
+ class User < ApplicationRecord
3
+ has_many :api_tokens, dependent: :destroy
4
+ has_many :plan_collaborators, dependent: :destroy
5
+
6
+ validates :external_id, presence: true, uniqueness: true
7
+ validates :name, presence: true
8
+ validates :email, uniqueness: true, allow_nil: true
9
+
10
+ after_initialize { self.metadata ||= {} }
11
+
12
+ def self.ransackable_attributes(auth_object = nil)
13
+ %w[id external_id name email admin created_at updated_at]
14
+ end
15
+
16
+ def self.ransackable_associations(auth_object = nil)
17
+ %w[api_tokens plan_collaborators]
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,14 @@
1
+ module CoPlan
2
+ class ApplicationPolicy
3
+ attr_reader :user, :record
4
+
5
+ def initialize(user, record)
6
+ @user = user
7
+ @record = record
8
+ end
9
+
10
+ def admin?
11
+ user.admin?
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,23 @@
1
+ module CoPlan
2
+ class CommentThreadPolicy < ApplicationPolicy
3
+ def create?
4
+ true
5
+ end
6
+
7
+ def resolve?
8
+ record.created_by_user_id == user.id || record.plan.created_by_user_id == user.id
9
+ end
10
+
11
+ def accept?
12
+ record.plan.created_by_user_id == user.id
13
+ end
14
+
15
+ def dismiss?
16
+ record.plan.created_by_user_id == user.id
17
+ end
18
+
19
+ def reopen?
20
+ record.created_by_user_id == user.id || record.plan.created_by_user_id == user.id
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,15 @@
1
+ module CoPlan
2
+ class PlanPolicy < ApplicationPolicy
3
+ def show?
4
+ true
5
+ end
6
+
7
+ def update?
8
+ record.created_by_user_id == user.id
9
+ end
10
+
11
+ def update_status?
12
+ update?
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,21 @@
1
+ module CoPlan
2
+ module AiProviders
3
+ class Anthropic
4
+ def self.call(system_prompt:, user_content:, model: "claude-sonnet-4-20250514")
5
+ new(system_prompt:, user_content:, model:).call
6
+ end
7
+
8
+ def initialize(system_prompt:, user_content:, model:)
9
+ @system_prompt = system_prompt
10
+ @user_content = user_content
11
+ @model = model
12
+ end
13
+
14
+ def call
15
+ raise Error, "Anthropic provider not yet implemented. Use OpenAI for now."
16
+ end
17
+
18
+ class Error < StandardError; end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,44 @@
1
+ module CoPlan
2
+ module AiProviders
3
+ class OpenAi
4
+ def self.call(system_prompt:, user_content:, model: "gpt-4o")
5
+ new(system_prompt:, user_content:, model:).call
6
+ end
7
+
8
+ def initialize(system_prompt:, user_content:, model:)
9
+ @system_prompt = system_prompt
10
+ @user_content = user_content
11
+ @model = model
12
+ end
13
+
14
+ def call
15
+ client = OpenAI::Client.new(access_token: api_key)
16
+
17
+ response = client.chat(
18
+ parameters: {
19
+ model: @model,
20
+ messages: [
21
+ { role: "system", content: @system_prompt },
22
+ { role: "user", content: @user_content }
23
+ ]
24
+ }
25
+ )
26
+
27
+ content = response.dig("choices", 0, "message", "content")
28
+ raise Error, "No response content from OpenAI" if content.blank?
29
+
30
+ content
31
+ end
32
+
33
+ private
34
+
35
+ def api_key
36
+ key = CoPlan.configuration.ai_api_key || Rails.application.credentials.dig(:openai, :api_key) || ENV["OPENAI_API_KEY"]
37
+ raise Error, "OpenAI API key not configured" if key.blank?
38
+ key
39
+ end
40
+
41
+ class Error < StandardError; end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,32 @@
1
+ module CoPlan
2
+ module Broadcaster
3
+ class << self
4
+ def prepend_to(streamable, target:, partial:, locals: {})
5
+ Turbo::StreamsChannel.broadcast_prepend_to(streamable, target: target, html: render(partial:, locals:))
6
+ end
7
+
8
+ def append_to(streamable, target:, partial:, locals: {})
9
+ Turbo::StreamsChannel.broadcast_append_to(streamable, target: target, html: render(partial:, locals:))
10
+ end
11
+
12
+ def replace_to(streamable, target:, html: nil, partial: nil, locals: {})
13
+ html ||= render(partial:, locals:)
14
+ Turbo::StreamsChannel.broadcast_replace_to(streamable, target: target, html: html)
15
+ end
16
+
17
+ def update_to(streamable, target:, html:)
18
+ Turbo::StreamsChannel.broadcast_update_to(streamable, target: target, html: html)
19
+ end
20
+
21
+ def remove_to(streamable, target:)
22
+ Turbo::StreamsChannel.broadcast_remove_to(streamable, target: target)
23
+ end
24
+
25
+ private
26
+
27
+ def render(partial:, locals:)
28
+ CoPlan::ApplicationController.render(partial: partial, locals: locals)
29
+ end
30
+ end
31
+ end
32
+ end