langextract 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/CHANGELOG.md +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +250 -0
- data/lib/langextract/config.rb +20 -0
- data/lib/langextract/core/annotation.rb +36 -0
- data/lib/langextract/core/chunking.rb +116 -0
- data/lib/langextract/core/data.rb +271 -0
- data/lib/langextract/core/errors.rb +13 -0
- data/lib/langextract/core/format_handler.rb +154 -0
- data/lib/langextract/core/prompt_validation.rb +52 -0
- data/lib/langextract/core/prompting.rb +72 -0
- data/lib/langextract/core/resolver.rb +261 -0
- data/lib/langextract/core/tokenizer.rb +50 -0
- data/lib/langextract/core/types.rb +21 -0
- data/lib/langextract/errors.rb +13 -0
- data/lib/langextract/extractor.rb +158 -0
- data/lib/langextract/factory.rb +61 -0
- data/lib/langextract/io.rb +35 -0
- data/lib/langextract/providers/base.rb +21 -0
- data/lib/langextract/providers/router.rb +35 -0
- data/lib/langextract/providers/ruby_llm.rb +37 -0
- data/lib/langextract/version.rb +5 -0
- data/lib/langextract/visualization.rb +106 -0
- data/lib/langextract.rb +53 -0
- metadata +74 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0aeb0a461910ee77f31dade0f57e4007a6525e8c4a8a8f8791f7a58a1b472926
|
|
4
|
+
data.tar.gz: c96a48d1f86d5257f5107a67b03a57b30e051ce877fd52b9d4b352fe54b57ed4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6c86ace0907fd9260cc93aad98c540aa269b5d6944d17d694775bc362d88865b6892b07fd1bc7a02794c25448ae724cd76b404c84274e427953ed6b092788565
|
|
7
|
+
data.tar.gz: af118cf15965bb7ef4d03fd8a2a5a07ba1b7631c84d346be42217e0d44bf0cafdc006dbceda56b255387eed87461c257680a197cdeb8662083fd2c2472043b85
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.1.0] - 2026-04-19
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Initial Ruby gem scaffold for `langextract`.
|
|
9
|
+
- Core data contracts, tokenization, chunking, prompting, parsing, resolver, provider routing, JSONL IO, and visualization.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# LangExtract
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/langextract)
|
|
4
|
+
[](https://github.com/dpaluy/langextract/actions/workflows/ci.yml)
|
|
5
|
+
|
|
6
|
+
A Ruby gem for extracting structured information from unstructured text using LLMs with precise source grounding and interactive visualization.
|
|
7
|
+
|
|
8
|
+
Ruby port of [LangExtract](https://github.com/google/langextract) v1.2.1.
|
|
9
|
+
|
|
10
|
+
Use it when a Ruby or Rails app needs structured LLM output that can be traced back to exact source spans instead of ungrounded JSON blobs.
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- **Source grounding** — every extraction includes character and token offsets back to the original text
|
|
15
|
+
- **Structured outputs** — deterministic, serializable result objects with alignment status
|
|
16
|
+
- **Long-document chunking** — sentence-aware chunking with sequential multi-pass extraction
|
|
17
|
+
- **Interactive visualization** — self-contained HTML highlighting of extraction spans
|
|
18
|
+
- **Format handling** — JSON and YAML output parsing with strict and lenient modes
|
|
19
|
+
- **Provider-agnostic** — pluggable LLM providers via [RubyLLM](https://github.com/crmne/ruby_llm)
|
|
20
|
+
|
|
21
|
+
## Requirements
|
|
22
|
+
|
|
23
|
+
- Ruby >= 3.4.5
|
|
24
|
+
- Tested on Ruby 3.4.5 and 4.0.2
|
|
25
|
+
- Optional live inference adapter: `ruby_llm` >= 1.0 when using `LangExtract::Factory.create_model`
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
gem "langextract"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Provider calls go through RubyLLM. Add RubyLLM to applications that need live model inference:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
bundle add ruby_llm
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
Configure RubyLLM the same way you already do in the host app. LangExtract does not own API keys or provider credentials:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
require "langextract"
|
|
45
|
+
require "ruby_llm"
|
|
46
|
+
|
|
47
|
+
RubyLLM.configure do |config|
|
|
48
|
+
config.openai_api_key = ENV.fetch("OPENAI_API_KEY")
|
|
49
|
+
config.default_model = "gpt-4o-mini"
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
LangExtract has only a small optional configuration surface:
|
|
54
|
+
|
|
55
|
+
| Option | ENV variable | Default |
|
|
56
|
+
|--------|--------------|---------|
|
|
57
|
+
| `default_model` | `LANGEXTRACT_MODEL` | RubyLLM's `default_model` |
|
|
58
|
+
|
|
59
|
+
Per-call model configuration can override the RubyLLM model or provider without touching credentials:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
model = LangExtract::Factory.create_model(
|
|
63
|
+
LangExtract::ModelConfig.new(
|
|
64
|
+
model: "gpt-4o-mini",
|
|
65
|
+
provider: "openai"
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
If you omit `model`, RubyLLM's configured `default_model` is used.
|
|
71
|
+
|
|
72
|
+
### Rails
|
|
73
|
+
|
|
74
|
+
Create `config/initializers/langextract.rb`:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
RubyLLM.configure do |config|
|
|
78
|
+
config.openai_api_key = Rails.application.credentials.dig(:openai, :api_key)
|
|
79
|
+
config.default_model = "gpt-4o-mini"
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Usage
|
|
84
|
+
|
|
85
|
+
### Extract
|
|
86
|
+
|
|
87
|
+
Build a provider and extract grounded fields:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
model = LangExtract::Factory.create_model
|
|
91
|
+
|
|
92
|
+
result = LangExtract.extract(
|
|
93
|
+
text: "Apple Inc. reported revenue of $94.8 billion for Q1 2024.",
|
|
94
|
+
model: model,
|
|
95
|
+
prompt_description: "Extract company financial data",
|
|
96
|
+
examples: [
|
|
97
|
+
LangExtract::ExampleData.new(
|
|
98
|
+
text: "Microsoft earned $56.5 billion in Q2 2023.",
|
|
99
|
+
extractions: [
|
|
100
|
+
{ text: "Microsoft", description: "company" },
|
|
101
|
+
{ text: "$56.5 billion", description: "revenue" },
|
|
102
|
+
{ text: "Q2 2023", description: "period" }
|
|
103
|
+
]
|
|
104
|
+
)
|
|
105
|
+
]
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
result.extractions.each do |extraction|
|
|
109
|
+
puts "#{extraction.text} (#{extraction.description}) #{extraction.char_interval}"
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Return value access pattern:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
first = result.extractions.first
|
|
117
|
+
first.text
|
|
118
|
+
first.extraction_class
|
|
119
|
+
first.char_interval.start_pos
|
|
120
|
+
first.char_interval.end_pos
|
|
121
|
+
first.alignment_status
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Document collections
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
documents = [
|
|
128
|
+
LangExtract::Document.new(id: "q1", text: "Apple reported revenue."),
|
|
129
|
+
LangExtract::Document.new(id: "q2", text: "Microsoft reported profit.")
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
annotated_documents = LangExtract.extract(
|
|
133
|
+
documents: documents,
|
|
134
|
+
model: model,
|
|
135
|
+
prompt_description: "Extract company names",
|
|
136
|
+
prompt_validation: :off
|
|
137
|
+
)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Visualization
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
html = LangExtract.visualize(result)
|
|
144
|
+
File.write("output.html", html)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`visualize` accepts a single `LangExtract::AnnotatedDocument`, an array of annotated documents, or a JSONL path.
|
|
148
|
+
|
|
149
|
+
### JSONL persistence
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
LangExtract::IO.save_annotated_documents("results.jsonl", documents)
|
|
153
|
+
documents = LangExtract::IO.load_annotated_documents_jsonl("results.jsonl")
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Format schema validation
|
|
157
|
+
|
|
158
|
+
`FormatHandler` can validate normalized extractions against a small JSON-schema-like contract:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
schema = {
|
|
162
|
+
required: %w[text extraction_class],
|
|
163
|
+
properties: {
|
|
164
|
+
text: { type: "string" },
|
|
165
|
+
extraction_class: { type: "string" },
|
|
166
|
+
attributes: { type: "object" }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
LangExtract::Core::FormatHandler.new.parse(model_output, schema: schema)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Error handling
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
begin
|
|
177
|
+
LangExtract.extract(...)
|
|
178
|
+
rescue LangExtract::InvalidModelConfigError => e
|
|
179
|
+
warn "Invalid model configuration: #{e.message}"
|
|
180
|
+
rescue LangExtract::ProviderConfigError => e
|
|
181
|
+
warn "Provider failed: #{e.message}"
|
|
182
|
+
rescue LangExtract::PromptValidationError, LangExtract::FormatParsingError => e
|
|
183
|
+
warn e.message
|
|
184
|
+
rescue LangExtract::AlignmentError => e
|
|
185
|
+
warn "Could not ground extraction: #{e.message}"
|
|
186
|
+
rescue LangExtract::IOFailure => e
|
|
187
|
+
warn "Could not read or write LangExtract data: #{e.message}"
|
|
188
|
+
end
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## API reference
|
|
192
|
+
|
|
193
|
+
- Upstream project: [google/langextract](https://github.com/google/langextract)
|
|
194
|
+
- Ruby API docs: [rubydoc.info/gems/langextract](https://rubydoc.info/gems/langextract)
|
|
195
|
+
|
|
196
|
+
## Current parity status
|
|
197
|
+
|
|
198
|
+
This is a Ruby gem slice against the Google LangExtract v1.2.1. It includes the core public contracts, an optional RubyLLM-backed provider adapter, and fixture-backed tests for deterministic local behavior.
|
|
199
|
+
|
|
200
|
+
The upstream v1.2.1 tag was collected with pytest into `test/fixtures/upstream/v1_2_1_pytest_manifest.json`: 404 deterministic tests plus 11 live API tests and 4 Ollama integration tests. That collection does not match the older PRD snapshot count of 479 deterministic / 494 total, so the count discrepancy must be reconciled before a 1.0 parity claim.
|
|
201
|
+
|
|
202
|
+
Deferred v1+ items:
|
|
203
|
+
|
|
204
|
+
- Full expected-output parity conversion for every deterministic upstream case in the manifest
|
|
205
|
+
- External plugin discovery from installed Ruby gems
|
|
206
|
+
- Batch inference workflows
|
|
207
|
+
- Concurrent provider calls
|
|
208
|
+
- URL fetching
|
|
209
|
+
|
|
210
|
+
## Development
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
bundle install
|
|
214
|
+
bundle exec rake test
|
|
215
|
+
bundle exec rubocop
|
|
216
|
+
bundle exec rake build
|
|
217
|
+
bundle exec yard
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Run a single test:
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
bundle exec ruby -Itest test/langextract/core/resolver_test.rb
|
|
224
|
+
bundle exec ruby -Itest test/langextract/core/resolver_test.rb -n test_aligns_exact_extraction_text_and_token_offsets
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Architecture
|
|
228
|
+
|
|
229
|
+
LangExtract follows a strict layered architecture:
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
Orchestrator
|
|
233
|
+
├── Prompting / Format Handling
|
|
234
|
+
├── Chunking / Tokenization
|
|
235
|
+
├── Resolver / Alignment ← center of gravity
|
|
236
|
+
├── Annotation
|
|
237
|
+
└── Provider (via RubyLLM)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**Core modules never depend on provider SDKs.** Provider output is normalized into an internal structure before reaching the resolver. The resolver handles exact and fuzzy alignment of extraction text back to source offsets — this is the most complex and critical module.
|
|
241
|
+
|
|
242
|
+
Differential test fixtures derived from the upstream Python library are the source of truth for behavioral parity.
|
|
243
|
+
|
|
244
|
+
## Contributing
|
|
245
|
+
|
|
246
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/dpaluy/langextract.
|
|
247
|
+
|
|
248
|
+
## License
|
|
249
|
+
|
|
250
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module LangExtract
|
|
6
|
+
class Config
|
|
7
|
+
attr_accessor :default_model, :logger
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@default_model = ENV.fetch("LANGEXTRACT_MODEL", nil)
|
|
11
|
+
@logger = default_logger
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def default_logger
|
|
17
|
+
defined?(Rails) ? Rails.logger : Logger.new($stderr)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resolver"
|
|
4
|
+
|
|
5
|
+
module LangExtract
|
|
6
|
+
module Core
|
|
7
|
+
class Annotator
|
|
8
|
+
def initialize(tokenizer: UnicodeTokenizer.new, fuzzy_threshold: Resolver::DEFAULT_FUZZY_THRESHOLD,
|
|
9
|
+
suppress_alignment_errors: true)
|
|
10
|
+
@tokenizer = tokenizer
|
|
11
|
+
@fuzzy_threshold = fuzzy_threshold
|
|
12
|
+
@suppress_alignment_errors = suppress_alignment_errors
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def annotate(document:, extraction_hashes:, preferred_interval: nil)
|
|
16
|
+
resolver = Resolver.new(
|
|
17
|
+
text: document.text,
|
|
18
|
+
tokenizer: tokenizer,
|
|
19
|
+
fuzzy_threshold: fuzzy_threshold,
|
|
20
|
+
suppress_alignment_errors: suppress_alignment_errors
|
|
21
|
+
)
|
|
22
|
+
extractions = resolver.resolve(
|
|
23
|
+
extraction_hashes,
|
|
24
|
+
document_id: document.id,
|
|
25
|
+
preferred_interval: preferred_interval
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
AnnotatedDocument.new(document: document, extractions: extractions)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :tokenizer, :fuzzy_threshold, :suppress_alignment_errors
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tokenizer"
|
|
4
|
+
|
|
5
|
+
module LangExtract
|
|
6
|
+
module Core
|
|
7
|
+
Chunk = Data.define(:text, :char_interval, :token_interval, :index, :document_id) do
|
|
8
|
+
def to_h
|
|
9
|
+
{
|
|
10
|
+
"text" => text,
|
|
11
|
+
"char_interval" => char_interval.to_h,
|
|
12
|
+
"token_interval" => token_interval&.to_h,
|
|
13
|
+
"index" => index,
|
|
14
|
+
"document_id" => document_id
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class SentenceAwareChunker
|
|
20
|
+
DEFAULT_MAX_CHAR_BUFFER = 2_000
|
|
21
|
+
|
|
22
|
+
def initialize(max_char_buffer: DEFAULT_MAX_CHAR_BUFFER, tokenizer: UnicodeTokenizer.new)
|
|
23
|
+
raise ArgumentError, "max_char_buffer must be positive" unless max_char_buffer.positive?
|
|
24
|
+
|
|
25
|
+
@max_char_buffer = max_char_buffer
|
|
26
|
+
@tokenizer = tokenizer
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def chunks(document)
|
|
30
|
+
text = document.respond_to?(:text) ? document.text : document.to_s
|
|
31
|
+
document_id = document.respond_to?(:id) ? document.id : nil
|
|
32
|
+
sentences = sentence_intervals(text)
|
|
33
|
+
token_lookup = token_lookup(text)
|
|
34
|
+
build_chunks(text, sentences, token_lookup, document_id)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
attr_reader :max_char_buffer, :tokenizer
|
|
40
|
+
|
|
41
|
+
def sentence_intervals(text)
|
|
42
|
+
intervals = []
|
|
43
|
+
start_pos = 0
|
|
44
|
+
|
|
45
|
+
text.to_enum(:scan, /[.!?]+[)"'\]]*(?:\s+|$)|\n{2,}/).each do
|
|
46
|
+
match = Regexp.last_match
|
|
47
|
+
end_pos = match.end(0)
|
|
48
|
+
intervals << CharInterval.new(start_pos: start_pos, end_pos: end_pos) if end_pos > start_pos
|
|
49
|
+
start_pos = end_pos
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
intervals << CharInterval.new(start_pos: start_pos, end_pos: text.length) if start_pos < text.length
|
|
53
|
+
intervals.reject { |interval| text[interval.start_pos...interval.end_pos].strip.empty? }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_chunks(text, sentences, token_lookup, document_id)
|
|
57
|
+
return [] if text.empty?
|
|
58
|
+
|
|
59
|
+
chunks = []
|
|
60
|
+
current_start = nil
|
|
61
|
+
current_end = nil
|
|
62
|
+
|
|
63
|
+
sentences.each do |sentence|
|
|
64
|
+
current_start ||= sentence.start_pos
|
|
65
|
+
if current_end && sentence.end_pos - current_start > max_char_buffer
|
|
66
|
+
chunks << build_chunk(text, current_start, current_end, token_lookup, chunks.length, document_id)
|
|
67
|
+
current_start = sentence.start_pos
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
current_end = sentence.end_pos
|
|
71
|
+
|
|
72
|
+
while current_end - current_start > max_char_buffer
|
|
73
|
+
split_end = split_position(text, current_start)
|
|
74
|
+
chunks << build_chunk(text, current_start, split_end, token_lookup, chunks.length, document_id)
|
|
75
|
+
current_start = split_end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if current_start && current_end
|
|
80
|
+
chunks << build_chunk(text, current_start, current_end, token_lookup, chunks.length, document_id)
|
|
81
|
+
end
|
|
82
|
+
chunks.freeze
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def split_position(text, start_pos)
|
|
86
|
+
limit = [start_pos + max_char_buffer, text.length].min
|
|
87
|
+
whitespace = text.rindex(/\s/, limit)
|
|
88
|
+
return limit unless whitespace && whitespace > start_pos
|
|
89
|
+
|
|
90
|
+
whitespace + 1
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def build_chunk(text, start_pos, end_pos, token_lookup, index, document_id)
|
|
94
|
+
interval = CharInterval.new(start_pos: start_pos, end_pos: end_pos)
|
|
95
|
+
Chunk.new(
|
|
96
|
+
text: text[start_pos...end_pos],
|
|
97
|
+
char_interval: interval,
|
|
98
|
+
token_interval: token_interval_for(interval, token_lookup),
|
|
99
|
+
index: index,
|
|
100
|
+
document_id: document_id
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def token_lookup(text)
|
|
105
|
+
tokenizer.tokenize(text)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def token_interval_for(char_interval, tokens)
|
|
109
|
+
matching = tokens.select { |token| token.char_interval.overlaps?(char_interval) }
|
|
110
|
+
return nil if matching.empty?
|
|
111
|
+
|
|
112
|
+
TokenInterval.new(start_pos: matching.first.index, end_pos: matching.last.index + 1)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|