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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +16 -0
- data/.gitignore +12 -0
- data/.rspec +3 -0
- data/.rubocop.yml +66 -0
- data/CHANGELOG.md +15 -0
- data/CLAUDE.md +80 -0
- data/Dockerfile +6 -0
- data/Gemfile +12 -0
- data/LICENSE +21 -0
- data/README.md +119 -0
- data/lex-rfp.gemspec +32 -0
- data/lib/legion/extensions/rfp/analytics/client.rb +31 -0
- data/lib/legion/extensions/rfp/analytics/helpers/client.rb +24 -0
- data/lib/legion/extensions/rfp/analytics/runners/metrics.rb +87 -0
- data/lib/legion/extensions/rfp/analytics/runners/quality.rb +121 -0
- data/lib/legion/extensions/rfp/analytics/runners/win_rates.rb +88 -0
- data/lib/legion/extensions/rfp/analytics.rb +16 -0
- data/lib/legion/extensions/rfp/generate/client.rb +31 -0
- data/lib/legion/extensions/rfp/generate/helpers/client.rb +24 -0
- data/lib/legion/extensions/rfp/generate/runners/drafts.rb +98 -0
- data/lib/legion/extensions/rfp/generate/runners/sections.rb +97 -0
- data/lib/legion/extensions/rfp/generate/runners/templates.rb +61 -0
- data/lib/legion/extensions/rfp/generate.rb +16 -0
- data/lib/legion/extensions/rfp/ingest/client.rb +31 -0
- data/lib/legion/extensions/rfp/ingest/helpers/client.rb +24 -0
- data/lib/legion/extensions/rfp/ingest/runners/corpus.rb +66 -0
- data/lib/legion/extensions/rfp/ingest/runners/documents.rb +86 -0
- data/lib/legion/extensions/rfp/ingest/runners/parser.rb +84 -0
- data/lib/legion/extensions/rfp/ingest.rb +16 -0
- data/lib/legion/extensions/rfp/review/client.rb +31 -0
- data/lib/legion/extensions/rfp/review/helpers/client.rb +24 -0
- data/lib/legion/extensions/rfp/review/runners/approvals.rb +70 -0
- data/lib/legion/extensions/rfp/review/runners/comments.rb +76 -0
- data/lib/legion/extensions/rfp/review/runners/workflows.rb +86 -0
- data/lib/legion/extensions/rfp/review.rb +16 -0
- data/lib/legion/extensions/rfp/version.rb +9 -0
- data/lib/legion/extensions/rfp.rb +15 -0
- 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
data/.rspec
ADDED
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
data/Gemfile
ADDED
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
|