agent-tome 1.0.0 → 1.0.1
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 +4 -4
- data/CHANGELOG.md +13 -1
- data/lib/agent/tome/article_formatter.rb +16 -0
- data/lib/agent/tome/commands/addend.rb +8 -97
- data/lib/agent/tome/commands/consolidate.rb +45 -50
- data/lib/agent/tome/commands/create.rb +8 -114
- data/lib/agent/tome/commands/fetch.rb +1 -7
- data/lib/agent/tome/commands/related.rb +5 -15
- data/lib/agent/tome/commands/search.rb +2 -15
- data/lib/agent/tome/file_source_linker.rb +18 -0
- data/lib/agent/tome/input_validator.rb +39 -0
- data/lib/agent/tome/keyword_linker.rb +17 -0
- data/lib/agent/tome/keyword_normalizer.rb +11 -0
- data/lib/agent/tome/related_article_linker.rb +19 -0
- data/lib/agent/tome/version.rb +1 -1
- data/lib/agent/tome/web_source_linker.rb +21 -0
- data/lib/agent/tome.rb +7 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 63a6785130bb342f557a1af651a7c774563b860b00c81c2f65669ae6d009885e
|
|
4
|
+
data.tar.gz: 7d164737f2babe237791b3d31c0f3e1693fb06933ced0aaa9f6a619dd29a1ff5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7b318e4534379a174ab7a06a784f72ba816ebf54ed86225619f27f99f7f43483e505129da5f3e7a0e2e1ea1c912e16a12b42627d5f8ae4242f0d21f14fd05ff4
|
|
7
|
+
data.tar.gz: 6fa6edcd247750d45f5e86e5bb0361597059e97cd6e3931c4f19d52f950a56a411280f236b3889c42795498f59fd086a7e44026728867224ef2177c274a9cc53
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.0.1] - 2026-04-05
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Extract KeywordNormalizer to eliminate triple duplication across Create, Addend, and Consolidate.
|
|
8
|
+
- Extract KeywordLinker, WebSourceLinker, FileSourceLinker, and RelatedArticleLinker, each with unit tests, removing duplicated logic from Create and Addend.
|
|
9
|
+
- Extract InputValidator with unit tests, wiring Create and Addend to share validation.
|
|
10
|
+
- Extract ArticleFormatter for shared article summary formatting.
|
|
11
|
+
- Refactor Consolidate.call into named private methods.
|
|
12
|
+
- Add test DSL helpers and migrate all acceptance tests to use them.
|
|
13
|
+
|
|
3
14
|
## [1.0.0] - 2026-04-03
|
|
4
15
|
|
|
5
16
|
### Added
|
|
@@ -18,5 +29,6 @@
|
|
|
18
29
|
- **CLI**: `agent-tome` executable with JSON stdin/stdout interface, exit code 0 on success and non-zero on error.
|
|
19
30
|
- **Claude Code skills**: Companion `tome-lookup` and `tome-capture` skills for AI agent integration.
|
|
20
31
|
|
|
21
|
-
[Unreleased]: https://github.com/beatmadsen/agent-tome/compare/v1.0.
|
|
32
|
+
[Unreleased]: https://github.com/beatmadsen/agent-tome/compare/v1.0.1...HEAD
|
|
33
|
+
[1.0.1]: https://github.com/beatmadsen/agent-tome/compare/v1.0.0...v1.0.1
|
|
22
34
|
[1.0.0]: https://github.com/beatmadsen/agent-tome/releases/tag/v1.0.0
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Agent
|
|
2
|
+
module Tome
|
|
3
|
+
module ArticleFormatter
|
|
4
|
+
def self.summary(article, extras = {})
|
|
5
|
+
result = {
|
|
6
|
+
"global_id" => article.global_id,
|
|
7
|
+
"description" => article.description,
|
|
8
|
+
"keywords" => article.keywords.pluck(:term).sort,
|
|
9
|
+
"created_at" => article.created_at.iso8601
|
|
10
|
+
}
|
|
11
|
+
result.merge!(extras) if extras.any?
|
|
12
|
+
result
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
require "active_support/core_ext/string/inflections"
|
|
2
|
-
|
|
3
1
|
module Agent
|
|
4
2
|
module Tome
|
|
5
3
|
module Commands
|
|
@@ -23,10 +21,10 @@ module Agent
|
|
|
23
21
|
created_at: Time.now
|
|
24
22
|
)
|
|
25
23
|
|
|
26
|
-
|
|
27
|
-
web_source_ids =
|
|
28
|
-
file_source_ids =
|
|
29
|
-
|
|
24
|
+
KeywordLinker.call(article, input["keywords"] || [])
|
|
25
|
+
web_source_ids = WebSourceLinker.call(entry, input["web_sources"] || [])
|
|
26
|
+
file_source_ids = FileSourceLinker.call(entry, input["file_sources"] || [])
|
|
27
|
+
RelatedArticleLinker.call(article, input["related_article_ids"] || [])
|
|
30
28
|
|
|
31
29
|
result = {
|
|
32
30
|
"entry_global_id" => entry.global_id,
|
|
@@ -59,99 +57,12 @@ module Agent
|
|
|
59
57
|
raise ValidationError, "At least one field must be substantively present"
|
|
60
58
|
end
|
|
61
59
|
|
|
62
|
-
validate_keywords!(keywords) if keywords.any?
|
|
63
|
-
validate_web_sources!(web_sources) if web_sources.any?
|
|
64
|
-
validate_file_sources!(file_sources) if file_sources.any?
|
|
65
|
-
validate_related_ids!(input["related_article_ids"]) if input.key?("related_article_ids")
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def validate_keywords!(keywords)
|
|
69
|
-
keywords.each do |kw|
|
|
70
|
-
raise ValidationError, "keyword must be a non-empty string" unless kw.is_a?(String) && !kw.strip.empty?
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def validate_web_sources!(sources)
|
|
75
|
-
sources.each do |src|
|
|
76
|
-
raise ValidationError, "invalid URL: #{src["url"]}" unless UrlNormalizer.valid?(src["url"].to_s)
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def validate_file_sources!(sources)
|
|
81
|
-
sources.each do |src|
|
|
82
|
-
raise ValidationError, "file_source path cannot be empty" if src["path"].to_s.strip.empty?
|
|
83
|
-
raise ValidationError, "file_source system_name cannot be empty" if src["system_name"].to_s.strip.empty?
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def validate_related_ids!(ids)
|
|
88
|
-
return unless ids
|
|
89
|
-
|
|
90
|
-
ids.each do |id|
|
|
91
|
-
raise ValidationError, "Referenced article not found: #{id}" unless Article.exists?(global_id: id)
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def process_keywords!(article, keywords)
|
|
96
|
-
keywords.each do |kw|
|
|
97
|
-
normalized = normalize_keyword(kw)
|
|
98
|
-
keyword = Keyword.find_or_create_by!(term: normalized) do |k|
|
|
99
|
-
k.created_at = Time.now
|
|
100
|
-
end
|
|
101
|
-
ArticleKeyword.find_or_create_by!(article: article, keyword: keyword) do |ak|
|
|
102
|
-
ak.created_at = Time.now
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def process_web_sources!(entry, sources)
|
|
108
|
-
sources.map do |src|
|
|
109
|
-
normalized_url = UrlNormalizer.normalize(src["url"])
|
|
110
|
-
ws = WebSource.find_or_create_by!(url: normalized_url) do |w|
|
|
111
|
-
w.global_id = GlobalId.generate
|
|
112
|
-
w.title = src["title"]
|
|
113
|
-
w.fetched_at = src["fetched_at"] ? Time.parse(src["fetched_at"]) : nil
|
|
114
|
-
w.created_at = Time.now
|
|
115
|
-
end
|
|
116
|
-
EntryWebSource.find_or_create_by!(entry: entry, web_source: ws) do |ews|
|
|
117
|
-
ews.created_at = Time.now
|
|
118
|
-
end
|
|
119
|
-
ws.global_id
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def process_file_sources!(entry, sources)
|
|
124
|
-
sources.map do |src|
|
|
125
|
-
fs = FileSource.find_or_create_by!(path: src["path"], system_name: src["system_name"]) do |f|
|
|
126
|
-
f.global_id = GlobalId.generate
|
|
127
|
-
f.created_at = Time.now
|
|
128
|
-
end
|
|
129
|
-
EntryFileSource.find_or_create_by!(entry: entry, file_source: fs) do |efs|
|
|
130
|
-
efs.created_at = Time.now
|
|
131
|
-
end
|
|
132
|
-
fs.global_id
|
|
133
|
-
end
|
|
60
|
+
InputValidator.validate_keywords!(keywords) if keywords.any?
|
|
61
|
+
InputValidator.validate_web_sources!(web_sources) if web_sources.any?
|
|
62
|
+
InputValidator.validate_file_sources!(file_sources) if file_sources.any?
|
|
63
|
+
InputValidator.validate_related_ids!(input["related_article_ids"]) if input.key?("related_article_ids")
|
|
134
64
|
end
|
|
135
65
|
|
|
136
|
-
def process_related_articles!(article, related_ids)
|
|
137
|
-
related_ids.each do |target_id|
|
|
138
|
-
raise ValidationError, "An article cannot reference itself" if target_id == article.global_id
|
|
139
|
-
|
|
140
|
-
target = Article.find_by!(global_id: target_id)
|
|
141
|
-
ArticleReference.find_or_create_by!(
|
|
142
|
-
source_article: article,
|
|
143
|
-
target_article: target
|
|
144
|
-
) do |ref|
|
|
145
|
-
ref.created_at = Time.now
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def normalize_keyword(kw)
|
|
151
|
-
words = kw.downcase.split("-")
|
|
152
|
-
words[-1] = ActiveSupport::Inflector.singularize(words[-1])
|
|
153
|
-
words.join("-")
|
|
154
|
-
end
|
|
155
66
|
end
|
|
156
67
|
end
|
|
157
68
|
end
|
|
@@ -12,61 +12,19 @@ module Agent
|
|
|
12
12
|
|
|
13
13
|
validate!(input)
|
|
14
14
|
|
|
15
|
-
result = {}
|
|
16
|
-
|
|
17
15
|
ActiveRecord::Base.transaction do
|
|
18
|
-
original_global_id = article
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
new_article = Article.create!(
|
|
27
|
-
global_id: original_global_id,
|
|
28
|
-
description: new_description,
|
|
29
|
-
created_at: Time.now
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
# Create the first (and only) entry for the consolidated article
|
|
33
|
-
new_entry = Entry.create!(
|
|
34
|
-
article: new_article,
|
|
35
|
-
body: input["body"],
|
|
36
|
-
created_at: Time.now
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
# Copy all sources from old article's entries to the consolidated entry
|
|
40
|
-
article.entries.each do |old_entry|
|
|
41
|
-
old_entry.web_sources.each do |ws|
|
|
42
|
-
EntryWebSource.find_or_create_by!(entry: new_entry, web_source: ws)
|
|
43
|
-
end
|
|
44
|
-
old_entry.file_sources.each do |fs|
|
|
45
|
-
EntryFileSource.find_or_create_by!(entry: new_entry, file_source: fs)
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Copy keywords from old article to new article
|
|
50
|
-
article.keywords.each do |keyword|
|
|
51
|
-
ArticleKeyword.find_or_create_by!(article: new_article, keyword: keyword) do |ak|
|
|
52
|
-
ak.created_at = Time.now
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# Create consolidation link
|
|
57
|
-
ConsolidationLink.create!(
|
|
58
|
-
new_article: new_article,
|
|
59
|
-
old_article: article,
|
|
60
|
-
created_at: Time.now
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
result = {
|
|
16
|
+
original_global_id = swap_global_id!(article)
|
|
17
|
+
new_article = create_consolidated_article(original_global_id, article, input)
|
|
18
|
+
new_entry = create_consolidated_entry(new_article, input)
|
|
19
|
+
copy_sources!(article, new_entry)
|
|
20
|
+
copy_keywords!(article, new_article)
|
|
21
|
+
ConsolidationLink.create!(new_article: new_article, old_article: article, created_at: Time.now)
|
|
22
|
+
|
|
23
|
+
{
|
|
64
24
|
"new_article_global_id" => new_article.global_id,
|
|
65
25
|
"old_article_global_id" => article.global_id
|
|
66
26
|
}
|
|
67
27
|
end
|
|
68
|
-
|
|
69
|
-
result
|
|
70
28
|
end
|
|
71
29
|
|
|
72
30
|
private
|
|
@@ -84,6 +42,43 @@ module Agent
|
|
|
84
42
|
raise ValidationError, "description must be 350 characters or fewer" if desc.length > 350
|
|
85
43
|
end
|
|
86
44
|
end
|
|
45
|
+
|
|
46
|
+
def swap_global_id!(article)
|
|
47
|
+
original_global_id = article.global_id
|
|
48
|
+
article.update_columns(global_id: GlobalId.generate)
|
|
49
|
+
original_global_id
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def create_consolidated_article(original_global_id, old_article, input)
|
|
53
|
+
Article.create!(
|
|
54
|
+
global_id: original_global_id,
|
|
55
|
+
description: input["description"] || old_article.description,
|
|
56
|
+
created_at: Time.now
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def create_consolidated_entry(article, input)
|
|
61
|
+
Entry.create!(article: article, body: input["body"], created_at: Time.now)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def copy_sources!(old_article, new_entry)
|
|
65
|
+
old_article.entries.each do |old_entry|
|
|
66
|
+
old_entry.web_sources.each do |ws|
|
|
67
|
+
EntryWebSource.find_or_create_by!(entry: new_entry, web_source: ws)
|
|
68
|
+
end
|
|
69
|
+
old_entry.file_sources.each do |fs|
|
|
70
|
+
EntryFileSource.find_or_create_by!(entry: new_entry, file_source: fs)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def copy_keywords!(old_article, new_article)
|
|
76
|
+
old_article.keywords.each do |keyword|
|
|
77
|
+
ArticleKeyword.find_or_create_by!(article: new_article, keyword: keyword) do |ak|
|
|
78
|
+
ak.created_at = Time.now
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
87
82
|
end
|
|
88
83
|
end
|
|
89
84
|
end
|
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
require "active_support/core_ext/string/inflections"
|
|
2
|
-
|
|
3
1
|
module Agent
|
|
4
2
|
module Tome
|
|
5
3
|
module Commands
|
|
6
4
|
class Create
|
|
7
|
-
TRACKING_PARAMS = %w[fbclid gclid fbid mc_cid mc_eid].freeze
|
|
8
|
-
|
|
9
5
|
def call(input)
|
|
10
6
|
validate!(input)
|
|
11
7
|
|
|
@@ -23,10 +19,10 @@ module Agent
|
|
|
23
19
|
created_at: Time.now
|
|
24
20
|
)
|
|
25
21
|
|
|
26
|
-
|
|
27
|
-
web_source_ids =
|
|
28
|
-
file_source_ids =
|
|
29
|
-
|
|
22
|
+
KeywordLinker.call(article, input["keywords"] || [])
|
|
23
|
+
web_source_ids = WebSourceLinker.call(entry, input["web_sources"] || [])
|
|
24
|
+
file_source_ids = FileSourceLinker.call(entry, input["file_sources"] || [])
|
|
25
|
+
RelatedArticleLinker.call(article, input["related_article_ids"] || [])
|
|
30
26
|
|
|
31
27
|
result = {
|
|
32
28
|
"article_global_id" => article.global_id,
|
|
@@ -54,114 +50,12 @@ module Agent
|
|
|
54
50
|
raise ValidationError, "body must be a string" unless body.is_a?(String)
|
|
55
51
|
raise ValidationError, "body cannot be blank" if body.strip.empty?
|
|
56
52
|
|
|
57
|
-
validate_keywords!(input["keywords"]) if input.key?("keywords")
|
|
58
|
-
validate_web_sources!(input["web_sources"]) if input.key?("web_sources")
|
|
59
|
-
validate_file_sources!(input["file_sources"]) if input.key?("file_sources")
|
|
60
|
-
validate_related_ids!(input["related_article_ids"]) if input.key?("related_article_ids")
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def validate_keywords!(keywords)
|
|
64
|
-
return unless keywords
|
|
65
|
-
|
|
66
|
-
raise ValidationError, "keywords must be an array" unless keywords.is_a?(Array)
|
|
67
|
-
|
|
68
|
-
keywords.each do |kw|
|
|
69
|
-
raise ValidationError, "keyword must be a non-empty string" unless kw.is_a?(String) && !kw.strip.empty?
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def validate_web_sources!(sources)
|
|
74
|
-
return unless sources
|
|
75
|
-
|
|
76
|
-
raise ValidationError, "web_sources must be an array" unless sources.is_a?(Array)
|
|
77
|
-
|
|
78
|
-
sources.each do |src|
|
|
79
|
-
raise ValidationError, "web_source url is required" unless src.is_a?(Hash) && src["url"]
|
|
80
|
-
raise ValidationError, "invalid URL: #{src["url"]}" unless UrlNormalizer.valid?(src["url"])
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def validate_file_sources!(sources)
|
|
85
|
-
return unless sources
|
|
86
|
-
|
|
87
|
-
raise ValidationError, "file_sources must be an array" unless sources.is_a?(Array)
|
|
88
|
-
|
|
89
|
-
sources.each do |src|
|
|
90
|
-
raise ValidationError, "file_source path cannot be empty" if src["path"].to_s.strip.empty?
|
|
91
|
-
raise ValidationError, "file_source system_name cannot be empty" if src["system_name"].to_s.strip.empty?
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def validate_related_ids!(ids)
|
|
96
|
-
return unless ids
|
|
97
|
-
|
|
98
|
-
raise ValidationError, "related_article_ids must be an array" unless ids.is_a?(Array)
|
|
99
|
-
|
|
100
|
-
ids.each do |id|
|
|
101
|
-
raise ValidationError, "Referenced article not found: #{id}" unless Article.exists?(global_id: id)
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def process_keywords!(article, keywords)
|
|
106
|
-
keywords.each do |kw|
|
|
107
|
-
normalized = normalize_keyword(kw)
|
|
108
|
-
keyword = Keyword.find_or_create_by!(term: normalized) do |k|
|
|
109
|
-
k.created_at = Time.now
|
|
110
|
-
end
|
|
111
|
-
ArticleKeyword.find_or_create_by!(article: article, keyword: keyword) do |ak|
|
|
112
|
-
ak.created_at = Time.now
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
def process_web_sources!(entry, sources)
|
|
118
|
-
sources.map do |src|
|
|
119
|
-
normalized_url = UrlNormalizer.normalize(src["url"])
|
|
120
|
-
ws = WebSource.find_or_create_by!(url: normalized_url) do |w|
|
|
121
|
-
w.global_id = GlobalId.generate
|
|
122
|
-
w.title = src["title"]
|
|
123
|
-
w.fetched_at = src["fetched_at"] ? Time.parse(src["fetched_at"]) : nil
|
|
124
|
-
w.created_at = Time.now
|
|
125
|
-
end
|
|
126
|
-
EntryWebSource.find_or_create_by!(entry: entry, web_source: ws) do |ews|
|
|
127
|
-
ews.created_at = Time.now
|
|
128
|
-
end
|
|
129
|
-
ws.global_id
|
|
130
|
-
end
|
|
53
|
+
InputValidator.validate_keywords!(input["keywords"]) if input.key?("keywords")
|
|
54
|
+
InputValidator.validate_web_sources!(input["web_sources"]) if input.key?("web_sources")
|
|
55
|
+
InputValidator.validate_file_sources!(input["file_sources"]) if input.key?("file_sources")
|
|
56
|
+
InputValidator.validate_related_ids!(input["related_article_ids"]) if input.key?("related_article_ids")
|
|
131
57
|
end
|
|
132
58
|
|
|
133
|
-
def process_file_sources!(entry, sources)
|
|
134
|
-
sources.map do |src|
|
|
135
|
-
fs = FileSource.find_or_create_by!(path: src["path"], system_name: src["system_name"]) do |f|
|
|
136
|
-
f.global_id = GlobalId.generate
|
|
137
|
-
f.created_at = Time.now
|
|
138
|
-
end
|
|
139
|
-
EntryFileSource.find_or_create_by!(entry: entry, file_source: fs) do |efs|
|
|
140
|
-
efs.created_at = Time.now
|
|
141
|
-
end
|
|
142
|
-
fs.global_id
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def process_related_articles!(article, related_ids)
|
|
147
|
-
related_ids.each do |target_id|
|
|
148
|
-
raise ValidationError, "An article cannot reference itself" if target_id == article.global_id
|
|
149
|
-
|
|
150
|
-
target = Article.find_by!(global_id: target_id)
|
|
151
|
-
ArticleReference.find_or_create_by!(
|
|
152
|
-
source_article: article,
|
|
153
|
-
target_article: target
|
|
154
|
-
) do |ref|
|
|
155
|
-
ref.created_at = Time.now
|
|
156
|
-
end
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
def normalize_keyword(kw)
|
|
161
|
-
words = kw.downcase.split("-")
|
|
162
|
-
words[-1] = ActiveSupport::Inflector.singularize(words[-1])
|
|
163
|
-
words.join("-")
|
|
164
|
-
end
|
|
165
59
|
end
|
|
166
60
|
end
|
|
167
61
|
end
|
|
@@ -10,13 +10,7 @@ module Agent
|
|
|
10
10
|
article = Article.find_by(global_id: @global_id)
|
|
11
11
|
raise NotFoundError, "Article not found: #{@global_id}" unless article
|
|
12
12
|
|
|
13
|
-
result =
|
|
14
|
-
"global_id" => article.global_id,
|
|
15
|
-
"description" => article.description,
|
|
16
|
-
"keywords" => article.keywords.pluck(:term).sort,
|
|
17
|
-
"created_at" => article.created_at.iso8601,
|
|
18
|
-
"entries" => format_entries(article)
|
|
19
|
-
}
|
|
13
|
+
result = ArticleFormatter.summary(article, "entries" => format_entries(article))
|
|
20
14
|
|
|
21
15
|
if (link = article.consolidation_as_new)
|
|
22
16
|
old = link.old_article
|
|
@@ -33,47 +33,37 @@ module Agent
|
|
|
33
33
|
.select("articles.*, COUNT(DISTINCT article_keywords.keyword_id) AS shared_keyword_count")
|
|
34
34
|
.order("shared_keyword_count DESC")
|
|
35
35
|
.limit(100)
|
|
36
|
-
.map { |a|
|
|
36
|
+
.map { |a| ArticleFormatter.summary(a, "shared_keyword_count" => a.shared_keyword_count.to_i) }
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def find_references_to(article)
|
|
40
40
|
ArticleReference
|
|
41
41
|
.where(source_article: article)
|
|
42
42
|
.includes(:target_article)
|
|
43
|
-
.map { |ref|
|
|
43
|
+
.map { |ref| ArticleFormatter.summary(ref.target_article) }
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def find_referenced_by(article)
|
|
47
47
|
ArticleReference
|
|
48
48
|
.where(target_article: article)
|
|
49
49
|
.includes(:source_article)
|
|
50
|
-
.map { |ref|
|
|
50
|
+
.map { |ref| ArticleFormatter.summary(ref.source_article) }
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
def find_consolidated_from(article)
|
|
54
54
|
ConsolidationLink
|
|
55
55
|
.where(new_article: article)
|
|
56
56
|
.includes(:old_article)
|
|
57
|
-
.map { |link|
|
|
57
|
+
.map { |link| ArticleFormatter.summary(link.old_article) }
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def find_consolidated_into(article)
|
|
61
61
|
ConsolidationLink
|
|
62
62
|
.where(old_article: article)
|
|
63
63
|
.includes(:new_article)
|
|
64
|
-
.map { |link|
|
|
64
|
+
.map { |link| ArticleFormatter.summary(link.new_article) }
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
-
def format_article(article, extra = {})
|
|
68
|
-
base = {
|
|
69
|
-
"global_id" => article.global_id,
|
|
70
|
-
"description" => article.description,
|
|
71
|
-
"keywords" => article.keywords.pluck(:term).sort,
|
|
72
|
-
"created_at" => article.created_at.iso8601
|
|
73
|
-
}
|
|
74
|
-
base.merge!(extra.transform_keys(&:to_s)) if extra.any?
|
|
75
|
-
base
|
|
76
|
-
end
|
|
77
67
|
end
|
|
78
68
|
end
|
|
79
69
|
end
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
require "active_support/core_ext/string/inflections"
|
|
2
|
-
|
|
3
1
|
module Agent
|
|
4
2
|
module Tome
|
|
5
3
|
module Commands
|
|
@@ -12,7 +10,7 @@ module Agent
|
|
|
12
10
|
def call
|
|
13
11
|
raise ValidationError, "At least one keyword is required" if @keywords.empty?
|
|
14
12
|
|
|
15
|
-
normalized = @keywords.map { |kw|
|
|
13
|
+
normalized = @keywords.map { |kw| KeywordNormalizer.call(kw) }
|
|
16
14
|
keyword_ids = Keyword.where(term: normalized).pluck(:id)
|
|
17
15
|
|
|
18
16
|
return { "results" => [] } if keyword_ids.empty?
|
|
@@ -42,20 +40,9 @@ module Agent
|
|
|
42
40
|
end
|
|
43
41
|
|
|
44
42
|
def format_result(article)
|
|
45
|
-
|
|
46
|
-
"global_id" => article.global_id,
|
|
47
|
-
"description" => article.description,
|
|
48
|
-
"keywords" => article.keywords.pluck(:term).sort,
|
|
49
|
-
"matching_keyword_count" => article.matching_keyword_count.to_i,
|
|
50
|
-
"created_at" => article.created_at.iso8601
|
|
51
|
-
}
|
|
43
|
+
ArticleFormatter.summary(article, "matching_keyword_count" => article.matching_keyword_count.to_i)
|
|
52
44
|
end
|
|
53
45
|
|
|
54
|
-
def normalize_keyword(kw)
|
|
55
|
-
words = kw.downcase.split("-")
|
|
56
|
-
words[-1] = ActiveSupport::Inflector.singularize(words[-1])
|
|
57
|
-
words.join("-")
|
|
58
|
-
end
|
|
59
46
|
end
|
|
60
47
|
end
|
|
61
48
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Agent
|
|
2
|
+
module Tome
|
|
3
|
+
module FileSourceLinker
|
|
4
|
+
def self.call(entry, sources)
|
|
5
|
+
sources.map do |src|
|
|
6
|
+
fs = FileSource.find_or_create_by!(path: src["path"], system_name: src["system_name"]) do |f|
|
|
7
|
+
f.global_id = GlobalId.generate
|
|
8
|
+
f.created_at = Time.now
|
|
9
|
+
end
|
|
10
|
+
EntryFileSource.find_or_create_by!(entry: entry, file_source: fs) do |efs|
|
|
11
|
+
efs.created_at = Time.now
|
|
12
|
+
end
|
|
13
|
+
fs.global_id
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Agent
|
|
2
|
+
module Tome
|
|
3
|
+
module InputValidator
|
|
4
|
+
def self.validate_keywords!(keywords)
|
|
5
|
+
raise ValidationError, "keywords must be an array" unless keywords.is_a?(Array)
|
|
6
|
+
|
|
7
|
+
keywords.each do |kw|
|
|
8
|
+
raise ValidationError, "keyword must be a non-empty string" unless kw.is_a?(String) && !kw.strip.empty?
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.validate_web_sources!(sources)
|
|
13
|
+
raise ValidationError, "web_sources must be an array" unless sources.is_a?(Array)
|
|
14
|
+
|
|
15
|
+
sources.each do |src|
|
|
16
|
+
raise ValidationError, "web_source url is required" unless src.is_a?(Hash) && src["url"]
|
|
17
|
+
raise ValidationError, "invalid URL: #{src["url"]}" unless UrlNormalizer.valid?(src["url"])
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.validate_file_sources!(sources)
|
|
22
|
+
raise ValidationError, "file_sources must be an array" unless sources.is_a?(Array)
|
|
23
|
+
|
|
24
|
+
sources.each do |src|
|
|
25
|
+
raise ValidationError, "file_source path cannot be empty" if src["path"].to_s.strip.empty?
|
|
26
|
+
raise ValidationError, "file_source system_name cannot be empty" if src["system_name"].to_s.strip.empty?
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.validate_related_ids!(ids)
|
|
31
|
+
raise ValidationError, "related_article_ids must be an array" unless ids.is_a?(Array)
|
|
32
|
+
|
|
33
|
+
ids.each do |id|
|
|
34
|
+
raise ValidationError, "Referenced article not found: #{id}" unless Article.exists?(global_id: id)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Agent
|
|
2
|
+
module Tome
|
|
3
|
+
module KeywordLinker
|
|
4
|
+
def self.call(article, keywords)
|
|
5
|
+
keywords.each do |kw|
|
|
6
|
+
normalized = KeywordNormalizer.call(kw)
|
|
7
|
+
keyword = Keyword.find_or_create_by!(term: normalized) do |k|
|
|
8
|
+
k.created_at = Time.now
|
|
9
|
+
end
|
|
10
|
+
ArticleKeyword.find_or_create_by!(article: article, keyword: keyword) do |ak|
|
|
11
|
+
ak.created_at = Time.now
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Agent
|
|
2
|
+
module Tome
|
|
3
|
+
module RelatedArticleLinker
|
|
4
|
+
def self.call(article, related_ids)
|
|
5
|
+
related_ids.each do |target_id|
|
|
6
|
+
raise ValidationError, "An article cannot reference itself" if target_id == article.global_id
|
|
7
|
+
|
|
8
|
+
target = Article.find_by!(global_id: target_id)
|
|
9
|
+
ArticleReference.find_or_create_by!(
|
|
10
|
+
source_article: article,
|
|
11
|
+
target_article: target
|
|
12
|
+
) do |ref|
|
|
13
|
+
ref.created_at = Time.now
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/agent/tome/version.rb
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Agent
|
|
2
|
+
module Tome
|
|
3
|
+
module WebSourceLinker
|
|
4
|
+
def self.call(entry, sources)
|
|
5
|
+
sources.map do |src|
|
|
6
|
+
normalized_url = UrlNormalizer.normalize(src["url"])
|
|
7
|
+
ws = WebSource.find_or_create_by!(url: normalized_url) do |w|
|
|
8
|
+
w.global_id = GlobalId.generate
|
|
9
|
+
w.title = src["title"]
|
|
10
|
+
w.fetched_at = src["fetched_at"] ? Time.parse(src["fetched_at"]) : nil
|
|
11
|
+
w.created_at = Time.now
|
|
12
|
+
end
|
|
13
|
+
EntryWebSource.find_or_create_by!(entry: entry, web_source: ws) do |ews|
|
|
14
|
+
ews.created_at = Time.now
|
|
15
|
+
end
|
|
16
|
+
ws.global_id
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/agent/tome.rb
CHANGED
|
@@ -12,6 +12,13 @@ require_relative "tome/config"
|
|
|
12
12
|
require_relative "tome/database"
|
|
13
13
|
require_relative "tome/global_id"
|
|
14
14
|
require_relative "tome/url_normalizer"
|
|
15
|
+
require_relative "tome/keyword_normalizer"
|
|
16
|
+
require_relative "tome/keyword_linker"
|
|
17
|
+
require_relative "tome/web_source_linker"
|
|
18
|
+
require_relative "tome/file_source_linker"
|
|
19
|
+
require_relative "tome/related_article_linker"
|
|
20
|
+
require_relative "tome/input_validator"
|
|
21
|
+
require_relative "tome/article_formatter"
|
|
15
22
|
|
|
16
23
|
module Agent
|
|
17
24
|
module Tome
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: agent-tome
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Erik T. Madsen
|
|
@@ -69,6 +69,7 @@ files:
|
|
|
69
69
|
- exe/agent-tome
|
|
70
70
|
- lib/agent.rb
|
|
71
71
|
- lib/agent/tome.rb
|
|
72
|
+
- lib/agent/tome/article_formatter.rb
|
|
72
73
|
- lib/agent/tome/cli.rb
|
|
73
74
|
- lib/agent/tome/commands/addend.rb
|
|
74
75
|
- lib/agent/tome/commands/consolidate.rb
|
|
@@ -80,7 +81,11 @@ files:
|
|
|
80
81
|
- lib/agent/tome/commands/source_search.rb
|
|
81
82
|
- lib/agent/tome/config.rb
|
|
82
83
|
- lib/agent/tome/database.rb
|
|
84
|
+
- lib/agent/tome/file_source_linker.rb
|
|
83
85
|
- lib/agent/tome/global_id.rb
|
|
86
|
+
- lib/agent/tome/input_validator.rb
|
|
87
|
+
- lib/agent/tome/keyword_linker.rb
|
|
88
|
+
- lib/agent/tome/keyword_normalizer.rb
|
|
84
89
|
- lib/agent/tome/models/application_record.rb
|
|
85
90
|
- lib/agent/tome/models/article.rb
|
|
86
91
|
- lib/agent/tome/models/article_keyword.rb
|
|
@@ -92,8 +97,10 @@ files:
|
|
|
92
97
|
- lib/agent/tome/models/file_source.rb
|
|
93
98
|
- lib/agent/tome/models/keyword.rb
|
|
94
99
|
- lib/agent/tome/models/web_source.rb
|
|
100
|
+
- lib/agent/tome/related_article_linker.rb
|
|
95
101
|
- lib/agent/tome/url_normalizer.rb
|
|
96
102
|
- lib/agent/tome/version.rb
|
|
103
|
+
- lib/agent/tome/web_source_linker.rb
|
|
97
104
|
homepage: https://github.com/beatmadsen/agent-tome
|
|
98
105
|
licenses:
|
|
99
106
|
- MIT
|