lex-rfp 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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +16 -0
  3. data/.gitignore +12 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +66 -0
  6. data/CHANGELOG.md +15 -0
  7. data/CLAUDE.md +80 -0
  8. data/Dockerfile +6 -0
  9. data/Gemfile +12 -0
  10. data/LICENSE +21 -0
  11. data/README.md +119 -0
  12. data/lex-rfp.gemspec +32 -0
  13. data/lib/legion/extensions/rfp/analytics/client.rb +31 -0
  14. data/lib/legion/extensions/rfp/analytics/helpers/client.rb +24 -0
  15. data/lib/legion/extensions/rfp/analytics/runners/metrics.rb +87 -0
  16. data/lib/legion/extensions/rfp/analytics/runners/quality.rb +121 -0
  17. data/lib/legion/extensions/rfp/analytics/runners/win_rates.rb +88 -0
  18. data/lib/legion/extensions/rfp/analytics.rb +16 -0
  19. data/lib/legion/extensions/rfp/generate/client.rb +31 -0
  20. data/lib/legion/extensions/rfp/generate/helpers/client.rb +24 -0
  21. data/lib/legion/extensions/rfp/generate/runners/drafts.rb +98 -0
  22. data/lib/legion/extensions/rfp/generate/runners/sections.rb +97 -0
  23. data/lib/legion/extensions/rfp/generate/runners/templates.rb +61 -0
  24. data/lib/legion/extensions/rfp/generate.rb +16 -0
  25. data/lib/legion/extensions/rfp/ingest/client.rb +31 -0
  26. data/lib/legion/extensions/rfp/ingest/helpers/client.rb +24 -0
  27. data/lib/legion/extensions/rfp/ingest/runners/corpus.rb +66 -0
  28. data/lib/legion/extensions/rfp/ingest/runners/documents.rb +86 -0
  29. data/lib/legion/extensions/rfp/ingest/runners/parser.rb +84 -0
  30. data/lib/legion/extensions/rfp/ingest.rb +16 -0
  31. data/lib/legion/extensions/rfp/review/client.rb +31 -0
  32. data/lib/legion/extensions/rfp/review/helpers/client.rb +24 -0
  33. data/lib/legion/extensions/rfp/review/runners/approvals.rb +70 -0
  34. data/lib/legion/extensions/rfp/review/runners/comments.rb +76 -0
  35. data/lib/legion/extensions/rfp/review/runners/workflows.rb +86 -0
  36. data/lib/legion/extensions/rfp/review.rb +16 -0
  37. data/lib/legion/extensions/rfp/version.rb +9 -0
  38. data/lib/legion/extensions/rfp.rb +15 -0
  39. metadata +99 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8c6ee3402b33896e13c6d7dbae38acbb7fa05e61bac53bb39a8a7478c9ddbb47
4
+ data.tar.gz: 400220b847975bc491724d112e0c3fd96084437696938bd5e428c6f4a08becd6
5
+ SHA512:
6
+ metadata.gz: fa341a874c0092ed018973520bb075dc1457decc4815fee304ef14fc8216f5e76473a00b454180e71f46387a33a99907d089408a1e3d712a577ba28d2d5e17c0
7
+ data.tar.gz: 380895e07cb7e8ff6fa2062a78dc24fcbeabffa0e5f9c6f437a8b2bcdb45645f38052c8302696b16cbba905110b3784228a92c58e8cdc0d80b6d7f24220b6b6d
@@ -0,0 +1,16 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+
7
+ jobs:
8
+ ci:
9
+ uses: LegionIO/.github/.github/workflows/ci.yml@main
10
+
11
+ release:
12
+ needs: ci
13
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
14
+ uses: LegionIO/.github/.github/workflows/release.yml@main
15
+ secrets:
16
+ rubygems-api-key: ${{ secrets.RUBYGEMS_API_KEY }}
data/.gitignore ADDED
@@ -0,0 +1,12 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ Gemfile.lock
10
+
11
+ # rspec failure tracking
12
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,66 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.4
3
+ NewCops: enable
4
+ SuggestExtensions: false
5
+
6
+ Layout/LineLength:
7
+ Max: 160
8
+
9
+ Layout/SpaceAroundEqualsInParameterDefault:
10
+ EnforcedStyle: space
11
+
12
+ Layout/HashAlignment:
13
+ EnforcedHashRocketStyle: table
14
+ EnforcedColonStyle: table
15
+
16
+ Metrics/MethodLength:
17
+ Max: 50
18
+
19
+ Metrics/ClassLength:
20
+ Max: 1500
21
+
22
+ Metrics/ModuleLength:
23
+ Max: 1500
24
+
25
+ Metrics/BlockLength:
26
+ Max: 40
27
+ Exclude:
28
+ - 'spec/**/*'
29
+
30
+ Metrics/AbcSize:
31
+ Max: 60
32
+
33
+ Metrics/CyclomaticComplexity:
34
+ Max: 15
35
+
36
+ Metrics/PerceivedComplexity:
37
+ Max: 17
38
+
39
+ Metrics/ParameterLists:
40
+ Max: 10
41
+
42
+ Style/Documentation:
43
+ Enabled: false
44
+
45
+ Style/SymbolArray:
46
+ Enabled: true
47
+
48
+ Style/FrozenStringLiteralComment:
49
+ Enabled: true
50
+ EnforcedStyle: always
51
+
52
+ Naming/FileName:
53
+ Enabled: false
54
+
55
+ Naming/PredicateMethod:
56
+ Enabled: false
57
+
58
+ Naming/PredicatePrefix:
59
+ Enabled: false
60
+
61
+ Gemspec/DevelopmentDependencies:
62
+ Enabled: false
63
+
64
+ Lint/EmptyClass:
65
+ Exclude:
66
+ - 'spec/**/*'
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2026-04-14
6
+
7
+ ### Added
8
+ - Initial scaffold with monolith architecture (4 sub-modules)
9
+ - Ingest sub-module: corpus loading for PDF, DOCX, Markdown, Excel, HTML documents
10
+ - Generate sub-module: draft RFP response generation via LLM pipeline with RAG
11
+ - Review sub-module: human-in-the-loop workflow with section-level status tracking
12
+ - Analytics sub-module: win rate tracking, proposal metrics, and quality scoring
13
+ - Standalone Client classes for each sub-module
14
+ - Full RSpec test suite
15
+ - GitHub Actions CI workflow
data/CLAUDE.md ADDED
@@ -0,0 +1,80 @@
1
+ # lex-rfp: RFP and Proposal Automation for LegionIO
2
+
3
+ **Repository Level 3 Documentation**
4
+ - **Category**: `/Users/miverso2/rubymine/legion/extensions/CLAUDE.md`
5
+
6
+ ## Purpose
7
+
8
+ Generative AI-powered RFP and proposal automation extension. Ingests past proposals and product documentation into Apollo, generates draft RFP responses via the LLM pipeline with RAG retrieval, provides human-in-the-loop review workflows, and tracks win rates with quality analytics.
9
+
10
+ **GitHub**: https://github.com/LegionIO/lex-rfp
11
+ **License**: MIT
12
+
13
+ ## Architecture
14
+
15
+ Monolith-style extension with four self-contained sub-modules:
16
+
17
+ ```
18
+ Legion::Extensions::Rfp
19
+ ├── Ingest/
20
+ │ ├── Runners/
21
+ │ │ ├── Documents # Format detection, text extraction, chunking
22
+ │ │ ├── Corpus # Document ingest pipeline, directory scanning, Apollo push
23
+ │ │ └── Parser # RFP question extraction, requirement identification, section splitting
24
+ │ ├── Helpers/Client # Faraday connection helper
25
+ │ └── Client # Standalone client including all Ingest runners
26
+ ├── Generate/
27
+ │ ├── Runners/
28
+ │ │ ├── Drafts # Full draft generation, single response, regeneration with feedback
29
+ │ │ ├── Sections # Section-specific responses, executive summary, compliance matrix
30
+ │ │ └── Templates # Template management (standard, government, healthcare), auto-suggest
31
+ │ ├── Helpers/Client
32
+ │ └── Client
33
+ ├── Review/
34
+ │ ├── Runners/
35
+ │ │ ├── Workflows # Workflow lifecycle (create, submit, finalize), section status tracking
36
+ │ │ ├── Comments # Comment system, revision requests, resolution
37
+ │ │ └── Approvals # Section/proposal approval, rejection, readiness checks
38
+ │ ├── Helpers/Client
39
+ │ └── Client
40
+ └── Analytics/
41
+ ├── Runners/
42
+ │ ├── Metrics # Proposal recording, outcome tracking, summary stats, response times
43
+ │ ├── WinRates # Overall/by-source/by-template win rates, trend analysis
44
+ │ └── Quality # Response quality scoring (4 dimensions), proposal scoring, aggregate reports
45
+ ├── Helpers/Client
46
+ └── Client
47
+ ```
48
+
49
+ ## Supported Document Formats
50
+
51
+ PDF, DOCX, Markdown, Excel (xlsx), HTML — PDF/DOCX/Excel extraction requires `legion-data` with Extract handlers.
52
+
53
+ ## Dependencies
54
+
55
+ | Gem | Purpose |
56
+ |-----|---------|
57
+ | `faraday` (>= 2.0) | HTTP client for all sub-modules |
58
+ | `legion-llm` (optional) | LLM pipeline for response generation |
59
+ | `legion-apollo` (optional) | RAG retrieval from knowledge store |
60
+ | `legion-data` (optional) | Document extraction (PDF, DOCX, Excel) |
61
+
62
+ ## Key Patterns
63
+
64
+ - **Monolith style**: Top entry point uses `require_relative` to load sub-modules; sub-module entries use bare `require`
65
+ - **Standalone Clients**: Each sub-module has its own `Client` class usable outside the framework
66
+ - **`client(**)` passthrough**: Runner methods accept `**` and pass through to `client(**)`
67
+ - **LLM integration**: Generate runners use `Legion::LLM.ask(message:)` with graceful fallback when LLM unavailable
68
+ - **Apollo integration**: Ingest pushes to Apollo via `Legion::Apollo.ingest`; Generate retrieves via `Legion::Apollo.retrieve`
69
+
70
+ ## Testing
71
+
72
+ ```bash
73
+ bundle install
74
+ bundle exec rspec
75
+ bundle exec rubocop
76
+ ```
77
+
78
+ ---
79
+
80
+ **Maintained By**: Matthew Iverson (@Esity)
data/Dockerfile ADDED
@@ -0,0 +1,6 @@
1
+ FROM legionio/legion
2
+
3
+ COPY . /usr/src/app/lex-rfp
4
+
5
+ WORKDIR /usr/src/app/lex-rfp
6
+ RUN bundle install
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+ gemspec
5
+
6
+ group :test do
7
+ gem 'rake'
8
+ gem 'rspec'
9
+ gem 'rspec_junit_formatter'
10
+ gem 'rubocop'
11
+ gem 'simplecov'
12
+ end
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Esity
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # lex-rfp
2
+
3
+ Generative AI-powered RFP and proposal automation for [LegionIO](https://github.com/LegionIO/LegionIO). Ingests past proposals and product documentation into Apollo, generates draft RFP responses via the LLM pipeline with RAG retrieval, provides human-in-the-loop review workflows, and tracks win rates with quality analytics.
4
+
5
+ ## Architecture
6
+
7
+ Monolith-style extension with four sub-modules:
8
+
9
+ | Sub-Module | Purpose |
10
+ |------------|---------|
11
+ | **Ingest** | Document parsing (PDF, DOCX, Markdown, Excel, HTML), text chunking, corpus management, Apollo ingestion |
12
+ | **Generate** | Draft response generation via LLM + RAG, section-by-section and full-document modes, template system |
13
+ | **Review** | Human-in-the-loop workflows, section-level status tracking, comments, approvals |
14
+ | **Analytics** | Win rate tracking, proposal metrics, response time stats, quality scoring |
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ gem install lex-rfp
20
+ ```
21
+
22
+ Or add to your Gemfile:
23
+
24
+ ```ruby
25
+ gem 'lex-rfp'
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ### Standalone Clients
31
+
32
+ Each sub-module provides a standalone `Client` class:
33
+
34
+ ```ruby
35
+ # Ingest documents
36
+ ingest = Legion::Extensions::Rfp::Ingest::Client.new
37
+ result = ingest.ingest_document(file_path: 'past_proposal.pdf', tags: ['healthcare'])
38
+ chunks = result[:result]
39
+
40
+ # Generate RFP responses
41
+ gen = Legion::Extensions::Rfp::Generate::Client.new
42
+ draft = gen.generate_response(question: 'Describe your network coverage')
43
+
44
+ # Review workflow
45
+ review = Legion::Extensions::Rfp::Review::Client.new
46
+ wf = review.create_workflow(proposal_id: 'prop-123', sections: %w[summary approach pricing])
47
+ review.submit_for_review(workflow_id: wf[:result][:id], reviewers: ['reviewer-1'])
48
+
49
+ # Analytics
50
+ analytics = Legion::Extensions::Rfp::Analytics::Client.new
51
+ rate = analytics.overall_win_rate(proposals: proposal_data)
52
+ ```
53
+
54
+ ### Ingest Functions
55
+
56
+ - `supported?(file_path:)` - Check if a file format is supported
57
+ - `extract_text(file_path:)` - Extract text from a document
58
+ - `chunk_text(text:, chunk_size:, overlap:)` - Split text into overlapping chunks
59
+ - `ingest_document(file_path:, tags:, metadata:)` - Full document ingest pipeline
60
+ - `ingest_directory(directory:, tags:, recursive:)` - Batch ingest all supported files
61
+ - `ingest_to_apollo(chunks:, scope:)` - Push chunks to Apollo knowledge store
62
+ - `parse_rfp_questions(text:)` - Extract numbered questions from RFP text
63
+ - `extract_requirements(text:)` - Identify mandatory and preferred requirements
64
+ - `extract_sections(text:)` - Split RFP into logical sections
65
+
66
+ ### Generate Functions
67
+
68
+ - `generate_full_draft(rfp_text:, context:, model:)` - Generate complete RFP response
69
+ - `generate_response(question:, context:, model:, scope:)` - Generate single question response with RAG
70
+ - `regenerate(question:, previous_answer:, feedback:)` - Revise response based on feedback
71
+ - `generate_section_response(question:, section:, context:)` - Section-specific response
72
+ - `generate_executive_summary(rfp_text:, company_context:)` - Executive summary generation
73
+ - `generate_compliance_matrix(requirements:, capabilities:)` - Compliance matrix
74
+ - `list_templates` / `get_template(name:)` / `apply_template(name:, rfp_data:)` - Template management
75
+ - `suggest_template(rfp_text:)` - Auto-suggest appropriate template
76
+
77
+ ### Review Functions
78
+
79
+ - `create_workflow(proposal_id:, sections:, reviewers:)` - Create review workflow
80
+ - `update_status(workflow_id:, status:)` / `update_section_status(...)` - Status management
81
+ - `submit_for_review(workflow_id:, reviewers:)` - Submit for review
82
+ - `finalize(workflow_id:)` - Finalize proposal
83
+ - `add_comment(...)` / `resolve_comment(...)` / `request_revision(...)` - Comment system
84
+ - `approve_section(...)` / `reject_section(...)` / `approve_proposal(...)` - Approvals
85
+ - `check_readiness(sections:)` - Check if all sections are approved
86
+
87
+ ### Analytics Functions
88
+
89
+ - `record_proposal(proposal_id:, rfp_source:, ...)` - Record proposal metadata
90
+ - `record_outcome(proposal_id:, outcome:, revenue:)` - Record win/loss outcome
91
+ - `summary(proposals:)` - Aggregate statistics
92
+ - `response_time_stats(proposals:)` - Response time analysis
93
+ - `overall_win_rate(proposals:)` - Overall win rate
94
+ - `win_rate_by_source(proposals:)` / `win_rate_by_template(proposals:)` - Segmented rates
95
+ - `trend(proposals:, period:)` - Win rate trends over time
96
+ - `score_response(response_text:, question:, requirements:)` - Quality scoring
97
+ - `score_proposal(sections:)` - Full proposal quality score
98
+ - `quality_report(proposals:)` - Aggregate quality report
99
+
100
+ ## Supported Document Formats
101
+
102
+ - PDF (via legion-data Extract)
103
+ - DOCX (via legion-data Extract)
104
+ - Markdown (.md, .markdown)
105
+ - Excel (.xlsx, via legion-data Extract)
106
+ - HTML (.html, .htm)
107
+
108
+ ## Requirements
109
+
110
+ - Ruby >= 3.4
111
+ - [LegionIO](https://github.com/LegionIO/LegionIO) framework
112
+ - `faraday` (>= 2.0)
113
+ - Optional: `legion-llm` for LLM-powered generation
114
+ - Optional: `legion-apollo` for RAG retrieval
115
+ - Optional: `legion-data` for PDF/DOCX/Excel extraction
116
+
117
+ ## License
118
+
119
+ MIT
data/lex-rfp.gemspec ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/rfp/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-rfp'
7
+ spec.version = Legion::Extensions::Rfp::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Rfp'
12
+ spec.description = 'Generative AI-powered RFP and proposal automation for LegionIO. ' \
13
+ 'Ingests past proposals into Apollo, generates draft responses via LLM pipeline with RAG, ' \
14
+ 'and provides human-in-the-loop review workflows with analytics.'
15
+ spec.homepage = 'https://github.com/LegionIO/lex-rfp'
16
+ spec.license = 'MIT'
17
+ spec.required_ruby_version = '>= 3.4'
18
+
19
+ spec.metadata['homepage_uri'] = spec.homepage
20
+ spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-rfp'
21
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-rfp'
22
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-rfp/blob/main/CHANGELOG.md'
23
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-rfp/issues'
24
+ spec.metadata['rubygems_mfa_required'] = 'true'
25
+
26
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
27
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
28
+ end
29
+ spec.require_paths = ['lib']
30
+
31
+ spec.add_dependency 'faraday', '>= 2.0'
32
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helpers/client'
4
+ require_relative 'runners/metrics'
5
+ require_relative 'runners/win_rates'
6
+ require_relative 'runners/quality'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module Rfp
11
+ module Analytics
12
+ class Client
13
+ include Helpers::Client
14
+ include Runners::Metrics
15
+ include Runners::WinRates
16
+ include Runners::Quality
17
+
18
+ attr_reader :opts
19
+
20
+ def initialize(base_url: nil, token: nil, **)
21
+ @opts = { base_url: base_url, token: token }.compact
22
+ end
23
+
24
+ def client(**override)
25
+ super(**@opts, **override)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Rfp
8
+ module Analytics
9
+ module Helpers
10
+ module Client
11
+ def client(base_url: 'http://localhost:4567', token: nil, **)
12
+ Faraday.new(url: base_url) do |conn|
13
+ conn.request :json
14
+ conn.response :json, content_type: /\bjson$/
15
+ conn.headers['Content-Type'] = 'application/json'
16
+ conn.headers['Authorization'] = "Bearer #{token}" if token
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Rfp
6
+ module Analytics
7
+ module Runners
8
+ module Metrics
9
+ extend Legion::Extensions::Rfp::Analytics::Helpers::Client
10
+
11
+ def record_proposal(proposal_id:, rfp_source:, submitted_at: nil, sections: 0, word_count: 0, **)
12
+ metric = {
13
+ proposal_id: proposal_id,
14
+ rfp_source: rfp_source,
15
+ submitted_at: submitted_at || Time.now.iso8601,
16
+ sections: sections,
17
+ word_count: word_count,
18
+ recorded_at: Time.now.iso8601
19
+ }
20
+ { result: metric }
21
+ end
22
+
23
+ def record_outcome(proposal_id:, outcome:, revenue: nil, feedback: nil, **)
24
+ valid_outcomes = %i[won lost no_decision pending]
25
+ outcome_sym = outcome.to_sym
26
+ return { result: nil, error: "Invalid outcome: #{outcome}. Valid: #{valid_outcomes.join(', ')}" } unless valid_outcomes.include?(outcome_sym)
27
+
28
+ {
29
+ result: {
30
+ proposal_id: proposal_id,
31
+ outcome: outcome_sym,
32
+ revenue: revenue,
33
+ feedback: feedback,
34
+ recorded_at: Time.now.iso8601
35
+ }
36
+ }
37
+ end
38
+
39
+ def summary(proposals:, **)
40
+ total = proposals.length
41
+ won = proposals.count { |p| p[:outcome] == :won }
42
+ lost = proposals.count { |p| p[:outcome] == :lost }
43
+ pending = proposals.count { |p| p[:outcome] == :pending || p[:outcome].nil? }
44
+ total_revenue = proposals.select { |p| p[:outcome] == :won }.sum { |p| p[:revenue].to_f }
45
+
46
+ {
47
+ result: {
48
+ total_proposals: total,
49
+ won: won,
50
+ lost: lost,
51
+ pending: pending,
52
+ win_rate: total.positive? ? (won.to_f / (won + lost)).round(4) : 0.0,
53
+ total_revenue: total_revenue
54
+ }
55
+ }
56
+ end
57
+
58
+ def response_time_stats(proposals:, **)
59
+ times = proposals.filter_map do |p|
60
+ next unless p[:created_at] && p[:submitted_at]
61
+
62
+ created = Time.parse(p[:created_at])
63
+ submitted = Time.parse(p[:submitted_at])
64
+ (submitted - created).to_f
65
+ end
66
+
67
+ return { result: { count: 0 } } if times.empty?
68
+
69
+ {
70
+ result: {
71
+ count: times.length,
72
+ avg: (times.sum / times.length).round(2),
73
+ min: times.min.round(2),
74
+ max: times.max.round(2),
75
+ median: times.sort[times.length / 2].round(2)
76
+ }
77
+ }
78
+ end
79
+
80
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
81
+ Legion::Extensions::Helpers.const_defined?(:Lex)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Rfp
6
+ module Analytics
7
+ module Runners
8
+ module Quality
9
+ extend Legion::Extensions::Rfp::Analytics::Helpers::Client
10
+
11
+ QUALITY_DIMENSIONS = %i[completeness relevance clarity compliance].freeze
12
+
13
+ def score_response(response_text:, question: nil, requirements: [], **)
14
+ scores = {
15
+ completeness: score_completeness(response_text),
16
+ relevance: score_relevance(response_text, question),
17
+ clarity: score_clarity(response_text),
18
+ compliance: score_compliance(response_text, requirements)
19
+ }
20
+
21
+ overall = scores.values.sum.to_f / scores.length
22
+ { result: { scores: scores, overall: overall.round(2) } }
23
+ end
24
+
25
+ def score_proposal(sections:, **)
26
+ section_scores = sections.map do |section|
27
+ scored = score_response(
28
+ response_text: section[:content] || '',
29
+ question: section[:question],
30
+ requirements: section[:requirements] || []
31
+ )
32
+ { name: section[:name], scores: scored[:result] }
33
+ end
34
+
35
+ avg_overall = if section_scores.empty?
36
+ 0.0
37
+ else
38
+ section_scores.sum { |s| s[:scores][:overall] } / section_scores.length
39
+ end
40
+
41
+ { result: { sections: section_scores, overall: avg_overall.round(2) } }
42
+ end
43
+
44
+ def quality_report(proposals:, **)
45
+ return { result: { count: 0 } } if proposals.empty?
46
+
47
+ avg_scores = QUALITY_DIMENSIONS.to_h do |dim|
48
+ scores = proposals.filter_map { |p| p.dig(:quality, :scores, dim) }
49
+ avg = scores.empty? ? 0.0 : (scores.sum.to_f / scores.length).round(2)
50
+ [dim, avg]
51
+ end
52
+
53
+ {
54
+ result: {
55
+ count: proposals.length,
56
+ average_scores: avg_scores,
57
+ overall: avg_scores.values.sum / avg_scores.length
58
+ }
59
+ }
60
+ end
61
+
62
+ private
63
+
64
+ def score_completeness(text)
65
+ return 0.0 if text.nil? || text.strip.empty?
66
+
67
+ length_score = [text.length / 500.0, 1.0].min
68
+ paragraph_score = [text.split(/\n\n+/).length / 3.0, 1.0].min
69
+ ((length_score + paragraph_score) / 2.0 * 100).round(2)
70
+ end
71
+
72
+ def score_relevance(text, question)
73
+ return 50.0 if question.nil? || text.nil?
74
+
75
+ keywords = question.to_s.downcase.scan(/\b\w{4,}\b/).uniq
76
+ return 50.0 if keywords.empty?
77
+
78
+ text_lower = text.downcase
79
+ matched = keywords.count { |kw| text_lower.include?(kw) }
80
+ ((matched.to_f / keywords.length) * 100).round(2)
81
+ end
82
+
83
+ def score_clarity(text)
84
+ return 0.0 if text.nil? || text.strip.empty?
85
+
86
+ sentences = text.split(/[.!?]+/).reject(&:empty?)
87
+ return 50.0 if sentences.empty?
88
+
89
+ avg_length = sentences.sum { |s| s.split.length }.to_f / sentences.length
90
+ length_score = if avg_length.between?(10, 25)
91
+ 100.0
92
+ elsif avg_length < 10
93
+ avg_length * 10.0
94
+ else
95
+ [100.0 - ((avg_length - 25) * 2), 0.0].max
96
+ end
97
+ length_score.round(2)
98
+ end
99
+
100
+ def score_compliance(text, requirements)
101
+ return 100.0 if requirements.empty?
102
+ return 0.0 if text.nil?
103
+
104
+ text_lower = text.downcase
105
+ matched = requirements.count do |req|
106
+ req_text = (req.is_a?(Hash) ? req[:text] : req).to_s.downcase
107
+ keywords = req_text.scan(/\b\w{4,}\b/).uniq
108
+ keywords.any? { |kw| text_lower.include?(kw) }
109
+ end
110
+
111
+ ((matched.to_f / requirements.length) * 100).round(2)
112
+ end
113
+
114
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
115
+ Legion::Extensions::Helpers.const_defined?(:Lex)
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end