feedsummary-core 2.2.0__tar.gz → 2.3.0__tar.gz
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.
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/.gitignore +3 -0
- {feedsummary_core-2.2.0/src/feedsummary_core.egg-info → feedsummary_core-2.3.0}/PKG-INFO +113 -4
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/README.md +107 -3
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/TAGGING_QUICKSTART.md +1 -1
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/TAGGING_SYSTEM.md +25 -27
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/pyproject.toml +7 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/_version.py +3 -3
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/persistence/MongoDBStore.py +79 -1
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/persistence/SqliteStore.py +21 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/persistence/TinyDbStore.py +14 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/persistence/__init__.py +11 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/batching.py +58 -6
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/ingest.py +229 -18
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/tagging.py +373 -47
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/tagging_integration.py +144 -7
- feedsummary_core-2.3.0/src/feedsummary_core/tagging_ml/__init__.py +51 -0
- feedsummary_core-2.3.0/src/feedsummary_core/tagging_ml/benchmark.py +976 -0
- feedsummary_core-2.3.0/src/feedsummary_core/tagging_ml/cli.py +169 -0
- feedsummary_core-2.3.0/src/feedsummary_core/tagging_ml/embedding_sgd.py +511 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/tagging_rules.py +19 -1
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0/src/feedsummary_core.egg-info}/PKG-INFO +113 -4
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core.egg-info/SOURCES.txt +9 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core.egg-info/entry_points.txt +1 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core.egg-info/requires.txt +10 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core.egg-info/scm_file_list.json +9 -0
- feedsummary_core-2.3.0/src/feedsummary_core.egg-info/scm_version.json +8 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/tests/test_embedding_persistence.py +35 -1
- feedsummary_core-2.3.0/tests/test_embedding_sgd_tagging.py +237 -0
- feedsummary_core-2.3.0/tests/test_ingest_rss_only.py +98 -0
- feedsummary_core-2.3.0/tests/test_ingest_tls.py +193 -0
- feedsummary_core-2.3.0/tests/test_ml_tagging_integration.py +198 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/tests/test_mongodb_store.py +56 -0
- feedsummary_core-2.3.0/tests/test_tag_matching.py +247 -0
- feedsummary_core-2.3.0/tests/test_tagging_cve_category.py +317 -0
- feedsummary_core-2.3.0/tests/test_tagging_ml.py +283 -0
- feedsummary_core-2.2.0/src/feedsummary_core.egg-info/scm_version.json +0 -8
- feedsummary_core-2.2.0/tests/test_tag_matching.py +0 -119
- feedsummary_core-2.2.0/tests/test_tagging_cve_category.py +0 -131
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/.github/workflows/release.yaml +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/LICENSE +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/TAGGING_INTEGRATION_GUIDE.md +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/TAG_BASED_SUMMARY.md +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/examples/tagging_examples.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/setup.cfg +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/__init__.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/llm_client/__init__.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/llm_client/fallback_client.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/llm_client/ollama_cloud.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/llm_client/ollama_local.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/persistence/CleanUpPolicy.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/persistence/helpers.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/persistence/migrate_tinydb_to_mongodb.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/prompts/loader.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/__init__.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/chat.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/helpers.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/main.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/prompt_lab.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/prompt_replay.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/summarizer.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/token_budget.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core.egg-info/dependency_links.txt +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core.egg-info/top_level.txt +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/tests/test_domain_extraction.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/tests/test_embedding_routing.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/tests/test_similarity_batching.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/tests/test_tag_based_summary.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/tests/test_tag_similarity_consistency.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/tests/test_tinydb_mongodb_migration.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: feedsummary-core
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Core library for FeedSummary.
|
|
5
5
|
Author: Martin Vesterlund
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -22,6 +22,11 @@ Requires-Dist: ollama>=0.6.1
|
|
|
22
22
|
Requires-Dist: lxml_html_clean>=0.4.3
|
|
23
23
|
Provides-Extra: mongodb
|
|
24
24
|
Requires-Dist: pymongo>=4.10; extra == "mongodb"
|
|
25
|
+
Provides-Extra: ml
|
|
26
|
+
Requires-Dist: pymongo>=4.10; extra == "ml"
|
|
27
|
+
Requires-Dist: joblib>=1.2; extra == "ml"
|
|
28
|
+
Requires-Dist: scikit-learn<1.8,>=1.7; python_version < "3.11" and extra == "ml"
|
|
29
|
+
Requires-Dist: scikit-learn<2,>=1.7; python_version >= "3.11" and extra == "ml"
|
|
25
30
|
Dynamic: license-file
|
|
26
31
|
|
|
27
32
|
# feedSummaryBase
|
|
@@ -188,9 +193,15 @@ Create a `config.yaml` file:
|
|
|
188
193
|
|
|
189
194
|
```yaml
|
|
190
195
|
feeds:
|
|
191
|
-
-
|
|
192
|
-
|
|
193
|
-
|
|
196
|
+
- name: "Example feed"
|
|
197
|
+
url: "https://example.com/feed.xml"
|
|
198
|
+
# Optional per-feed minimum TLS version, applied to the feed and its articles.
|
|
199
|
+
# Supported values: "1.2" and "1.3".
|
|
200
|
+
tls_min_version: "1.3"
|
|
201
|
+
# Use native curl for servers that reject aiohttp's TLS fingerprint.
|
|
202
|
+
http_client: "curl"
|
|
203
|
+
# Optional: use only text embedded in the RSS entry and never fetch its link.
|
|
204
|
+
fetch_article: false
|
|
194
205
|
|
|
195
206
|
store:
|
|
196
207
|
provider: "sqlite" # sqlite, tinydb, or mongodb
|
|
@@ -219,6 +230,104 @@ tagging:
|
|
|
219
230
|
max_shared_tags: 1
|
|
220
231
|
```
|
|
221
232
|
|
|
233
|
+
### Embedding-based ML tagging
|
|
234
|
+
|
|
235
|
+
The production ML path uses a classifier selected in configuration with
|
|
236
|
+
persisted article embeddings. Both the classifier and the category scope are
|
|
237
|
+
configuration-driven; `categories` accepts one or more database category names.
|
|
238
|
+
On each tagging run, a corpus fingerprint detects changed article-tag
|
|
239
|
+
associations, classifier settings, or category scope and atomically retrains the
|
|
240
|
+
artifact when needed. Full retraining correctly handles tags that were removed
|
|
241
|
+
or corrected.
|
|
242
|
+
|
|
243
|
+
```yaml
|
|
244
|
+
tagging:
|
|
245
|
+
# Existing tags in these categories may be selected by the LLM, but the LLM
|
|
246
|
+
# may not create new tags in them.
|
|
247
|
+
llm_new_tag_excluded_categories: [DOMAIN_ENTITY]
|
|
248
|
+
ml:
|
|
249
|
+
enabled: false
|
|
250
|
+
classifier: logistic_regression # logistic_regression or sgd
|
|
251
|
+
representation: embedding
|
|
252
|
+
categories: [DOMAIN_ENTITY]
|
|
253
|
+
embedding_model: "" # inherit from the local embedding provider
|
|
254
|
+
embedding_text_chars: 2000
|
|
255
|
+
model_path: data/tagging_ml/embedding_classifier.joblib
|
|
256
|
+
min_label_support: 3
|
|
257
|
+
min_training_articles: 30
|
|
258
|
+
max_tags_per_article: 5
|
|
259
|
+
threshold: 0.8014432738078289
|
|
260
|
+
regularization_c: 1.0 # logistic_regression only
|
|
261
|
+
alpha: 0.0001 # sgd only
|
|
262
|
+
max_iter: 2000
|
|
263
|
+
tolerance: 0.0001
|
|
264
|
+
random_state: 42
|
|
265
|
+
auto_retrain: true
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
To change or extend the scope, edit only the category list, for example
|
|
269
|
+
`categories: [DOMAIN_ENTITY, LOCATION]`. The artifact is rejected and retrained
|
|
270
|
+
when its configured classifier or category list no longer matches.
|
|
271
|
+
|
|
272
|
+
When a compatible embedding or model is unavailable, tagging safely falls back
|
|
273
|
+
to the existing LLM path. `llm_new_tag_excluded_categories` still applies to
|
|
274
|
+
that fallback, so it may reuse an existing `DOMAIN_ENTITY` tag but cannot add a
|
|
275
|
+
new one. Joblib artifacts must only be loaded from trusted local paths.
|
|
276
|
+
|
|
277
|
+
ML tagging emits searchable JSON payloads after stable event names:
|
|
278
|
+
|
|
279
|
+
- `ml_tagging.model_ready` records the classifier, categories, threshold, model
|
|
280
|
+
version, label count, and training corpus metadata.
|
|
281
|
+
- `ml_tagging.predictions` records the article ID and every accepted ML tag with
|
|
282
|
+
category and probability. An empty `suggestions` list means that the model ran
|
|
283
|
+
but no score met the configured threshold.
|
|
284
|
+
- `ml_tagging.skipped` records incompatible or missing article embeddings, while
|
|
285
|
+
`ml_tagging.model_unavailable` records initialization and training failures.
|
|
286
|
+
- `ml_tagging.below_threshold` records the five strongest rejected candidates
|
|
287
|
+
at DEBUG level.
|
|
288
|
+
|
|
289
|
+
No article text is included in these events. Accepted ML assignments also keep
|
|
290
|
+
the classifier and rounded probability in the persisted `motivering` field.
|
|
291
|
+
|
|
292
|
+
### Benchmark classical ML tagging
|
|
293
|
+
|
|
294
|
+
The first ML tagging stage is an offline, read-only benchmark against historical
|
|
295
|
+
MongoDB tag assignments. It does not change the production LLM tagging flow.
|
|
296
|
+
Install the optional dependencies and add an ML section to the normal config:
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
pip install "feedsummary-core[ml]"
|
|
300
|
+
feedsummary-benchmark-tags --config config.yaml --output-dir artifacts/tag-benchmark
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
```yaml
|
|
304
|
+
store:
|
|
305
|
+
provider: mongodb
|
|
306
|
+
uri: mongodb://localhost:27017
|
|
307
|
+
database: feedsummary
|
|
308
|
+
|
|
309
|
+
tagging:
|
|
310
|
+
ml:
|
|
311
|
+
categories: [GENERAL]
|
|
312
|
+
min_label_support: 10
|
|
313
|
+
max_tags_per_article: 5
|
|
314
|
+
max_text_chars: 20000
|
|
315
|
+
random_seed: 42
|
|
316
|
+
n_jobs: 1
|
|
317
|
+
max_category_combinations: 63
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Each run writes a JSON report, a compact Markdown report, and the winning
|
|
321
|
+
scikit-learn pipeline. Historical tags are treated as labels and may contain
|
|
322
|
+
LLM-generated noise. Only load the generated Joblib model from a trusted source.
|
|
323
|
+
When multiple categories are configured, every non-empty category combination
|
|
324
|
+
is evaluated. Ranking uses chronological validation micro-F1 adjusted for tag
|
|
325
|
+
assignment coverage: tags excluded by `min_label_support` count as unpredicted.
|
|
326
|
+
The report shows raw F1 alongside label and assignment coverage, so a category
|
|
327
|
+
with one easy, well-supported tag cannot win merely because its many rare tags
|
|
328
|
+
were filtered out. The limit above prevents an accidental exponential benchmark
|
|
329
|
+
when too many categories are supplied.
|
|
330
|
+
|
|
222
331
|
After all articles in a run have been tagged, similarity consistency checks
|
|
223
332
|
embedding-based article groups for tag overlap. If a group has no tag shared by
|
|
224
333
|
all members, the most common existing general tag is added to the missing
|
|
@@ -162,9 +162,15 @@ Create a `config.yaml` file:
|
|
|
162
162
|
|
|
163
163
|
```yaml
|
|
164
164
|
feeds:
|
|
165
|
-
-
|
|
166
|
-
|
|
167
|
-
|
|
165
|
+
- name: "Example feed"
|
|
166
|
+
url: "https://example.com/feed.xml"
|
|
167
|
+
# Optional per-feed minimum TLS version, applied to the feed and its articles.
|
|
168
|
+
# Supported values: "1.2" and "1.3".
|
|
169
|
+
tls_min_version: "1.3"
|
|
170
|
+
# Use native curl for servers that reject aiohttp's TLS fingerprint.
|
|
171
|
+
http_client: "curl"
|
|
172
|
+
# Optional: use only text embedded in the RSS entry and never fetch its link.
|
|
173
|
+
fetch_article: false
|
|
168
174
|
|
|
169
175
|
store:
|
|
170
176
|
provider: "sqlite" # sqlite, tinydb, or mongodb
|
|
@@ -193,6 +199,104 @@ tagging:
|
|
|
193
199
|
max_shared_tags: 1
|
|
194
200
|
```
|
|
195
201
|
|
|
202
|
+
### Embedding-based ML tagging
|
|
203
|
+
|
|
204
|
+
The production ML path uses a classifier selected in configuration with
|
|
205
|
+
persisted article embeddings. Both the classifier and the category scope are
|
|
206
|
+
configuration-driven; `categories` accepts one or more database category names.
|
|
207
|
+
On each tagging run, a corpus fingerprint detects changed article-tag
|
|
208
|
+
associations, classifier settings, or category scope and atomically retrains the
|
|
209
|
+
artifact when needed. Full retraining correctly handles tags that were removed
|
|
210
|
+
or corrected.
|
|
211
|
+
|
|
212
|
+
```yaml
|
|
213
|
+
tagging:
|
|
214
|
+
# Existing tags in these categories may be selected by the LLM, but the LLM
|
|
215
|
+
# may not create new tags in them.
|
|
216
|
+
llm_new_tag_excluded_categories: [DOMAIN_ENTITY]
|
|
217
|
+
ml:
|
|
218
|
+
enabled: false
|
|
219
|
+
classifier: logistic_regression # logistic_regression or sgd
|
|
220
|
+
representation: embedding
|
|
221
|
+
categories: [DOMAIN_ENTITY]
|
|
222
|
+
embedding_model: "" # inherit from the local embedding provider
|
|
223
|
+
embedding_text_chars: 2000
|
|
224
|
+
model_path: data/tagging_ml/embedding_classifier.joblib
|
|
225
|
+
min_label_support: 3
|
|
226
|
+
min_training_articles: 30
|
|
227
|
+
max_tags_per_article: 5
|
|
228
|
+
threshold: 0.8014432738078289
|
|
229
|
+
regularization_c: 1.0 # logistic_regression only
|
|
230
|
+
alpha: 0.0001 # sgd only
|
|
231
|
+
max_iter: 2000
|
|
232
|
+
tolerance: 0.0001
|
|
233
|
+
random_state: 42
|
|
234
|
+
auto_retrain: true
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
To change or extend the scope, edit only the category list, for example
|
|
238
|
+
`categories: [DOMAIN_ENTITY, LOCATION]`. The artifact is rejected and retrained
|
|
239
|
+
when its configured classifier or category list no longer matches.
|
|
240
|
+
|
|
241
|
+
When a compatible embedding or model is unavailable, tagging safely falls back
|
|
242
|
+
to the existing LLM path. `llm_new_tag_excluded_categories` still applies to
|
|
243
|
+
that fallback, so it may reuse an existing `DOMAIN_ENTITY` tag but cannot add a
|
|
244
|
+
new one. Joblib artifacts must only be loaded from trusted local paths.
|
|
245
|
+
|
|
246
|
+
ML tagging emits searchable JSON payloads after stable event names:
|
|
247
|
+
|
|
248
|
+
- `ml_tagging.model_ready` records the classifier, categories, threshold, model
|
|
249
|
+
version, label count, and training corpus metadata.
|
|
250
|
+
- `ml_tagging.predictions` records the article ID and every accepted ML tag with
|
|
251
|
+
category and probability. An empty `suggestions` list means that the model ran
|
|
252
|
+
but no score met the configured threshold.
|
|
253
|
+
- `ml_tagging.skipped` records incompatible or missing article embeddings, while
|
|
254
|
+
`ml_tagging.model_unavailable` records initialization and training failures.
|
|
255
|
+
- `ml_tagging.below_threshold` records the five strongest rejected candidates
|
|
256
|
+
at DEBUG level.
|
|
257
|
+
|
|
258
|
+
No article text is included in these events. Accepted ML assignments also keep
|
|
259
|
+
the classifier and rounded probability in the persisted `motivering` field.
|
|
260
|
+
|
|
261
|
+
### Benchmark classical ML tagging
|
|
262
|
+
|
|
263
|
+
The first ML tagging stage is an offline, read-only benchmark against historical
|
|
264
|
+
MongoDB tag assignments. It does not change the production LLM tagging flow.
|
|
265
|
+
Install the optional dependencies and add an ML section to the normal config:
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
pip install "feedsummary-core[ml]"
|
|
269
|
+
feedsummary-benchmark-tags --config config.yaml --output-dir artifacts/tag-benchmark
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
```yaml
|
|
273
|
+
store:
|
|
274
|
+
provider: mongodb
|
|
275
|
+
uri: mongodb://localhost:27017
|
|
276
|
+
database: feedsummary
|
|
277
|
+
|
|
278
|
+
tagging:
|
|
279
|
+
ml:
|
|
280
|
+
categories: [GENERAL]
|
|
281
|
+
min_label_support: 10
|
|
282
|
+
max_tags_per_article: 5
|
|
283
|
+
max_text_chars: 20000
|
|
284
|
+
random_seed: 42
|
|
285
|
+
n_jobs: 1
|
|
286
|
+
max_category_combinations: 63
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Each run writes a JSON report, a compact Markdown report, and the winning
|
|
290
|
+
scikit-learn pipeline. Historical tags are treated as labels and may contain
|
|
291
|
+
LLM-generated noise. Only load the generated Joblib model from a trusted source.
|
|
292
|
+
When multiple categories are configured, every non-empty category combination
|
|
293
|
+
is evaluated. Ranking uses chronological validation micro-F1 adjusted for tag
|
|
294
|
+
assignment coverage: tags excluded by `min_label_support` count as unpredicted.
|
|
295
|
+
The report shows raw F1 alongside label and assignment coverage, so a category
|
|
296
|
+
with one easy, well-supported tag cannot win merely because its many rare tags
|
|
297
|
+
were filtered out. The limit above prevents an accidental exponential benchmark
|
|
298
|
+
when too many categories are supplied.
|
|
299
|
+
|
|
196
300
|
After all articles in a run have been tagged, similarity consistency checks
|
|
197
301
|
embedding-based article groups for tag overlap. If a group has no tag shared by
|
|
198
302
|
all members, the most common existing general tag is added to the missing
|
|
@@ -167,7 +167,7 @@ Edit `tagging.py` to modify patterns:
|
|
|
167
167
|
|
|
168
168
|
```python
|
|
169
169
|
# Add CVE variants
|
|
170
|
-
CVE_PATTERN = re.compile(r'CVE
|
|
170
|
+
CVE_PATTERN = re.compile(r'CVE-[0-9]{4}-[0-9]{4,19}|GHSA-\w{4}-\w{4}-\w{4}')
|
|
171
171
|
|
|
172
172
|
# Add threat actor keywords
|
|
173
173
|
THREAT_ACTOR_KEYWORDS = {
|
|
@@ -86,28 +86,26 @@ CREATE TABLE article_tags (
|
|
|
86
86
|
|
|
87
87
|
## Taggkategorier
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
- **Hot-aktörer**: APT-grupper, kampanjnamn (t.ex. "APT28", "Lazarus Group")
|
|
98
|
-
- **Geografiska regioner**: Länder, kontinenter (t.ex. "Russia", "North Korea")
|
|
99
|
-
- **Sårbarheter**: Specifika sårbarhetsnamn och typer
|
|
89
|
+
Kategorier hämtas dynamiskt från databasens `tag_categories`. LLM:en får
|
|
90
|
+
kategoriernas namn, etikett och beskrivning och väljer den mest specifika
|
|
91
|
+
lämpliga kategorin för varje ny tagg. Valet valideras case-insensitivt mot
|
|
92
|
+
databasen och lagras med kategorins kanoniska namn.
|
|
93
|
+
|
|
94
|
+
Om LLM:en inte anger en giltig kategori används en definierad standardkategori,
|
|
95
|
+
normalt `GENERAL` eller `DOMAIN_ENTITY`. CVE-taggar är ett särskilt undantag och
|
|
96
|
+
tvingas alltid till `VULNERABILITY`.
|
|
100
97
|
|
|
101
98
|
## Taggprioriteringsalgoritm
|
|
102
99
|
|
|
103
100
|
```
|
|
104
|
-
1.
|
|
105
|
-
2.
|
|
101
|
+
1. Hämta tillgängliga kategorier från databasen
|
|
102
|
+
2. Extrahera kandidattaggar och kategori från artikel (via LLM)
|
|
103
|
+
3. För varje kandidattagg:
|
|
106
104
|
a. Sök efter befintliga liknande taggar
|
|
107
105
|
b. Om befintlig tagg finns → använd den
|
|
108
|
-
c. Om
|
|
109
|
-
d.
|
|
110
|
-
|
|
106
|
+
c. Om en ny tagg ska skapas → validera LLM-kategorin mot databasen
|
|
107
|
+
d. Skapa taggen med databasens kanoniska kategorinamn
|
|
108
|
+
4. Lagra valda taggar i databasen
|
|
111
109
|
```
|
|
112
110
|
|
|
113
111
|
### Likhetsmätning
|
|
@@ -215,23 +213,23 @@ print(f"Removed {removed} unused tags")
|
|
|
215
213
|
Systemet använder följande prompt-mall för att få LLM att extrahera taggar:
|
|
216
214
|
|
|
217
215
|
```
|
|
218
|
-
Analyze the following article and extract up to {max_tags} relevant tags.
|
|
216
|
+
Analyze the following article and extract up to {max_tags} relevant non-CVE tags.
|
|
219
217
|
|
|
220
218
|
IMPORTANT RULES:
|
|
221
|
-
1.
|
|
222
|
-
2.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
4. Keep tags concise (1-3 words max)
|
|
226
|
-
5. Focus on the main topics and entities mentioned
|
|
219
|
+
1. Select "category" from the database categories included in the prompt
|
|
220
|
+
2. Use the most specific suitable category and GENERAL as fallback
|
|
221
|
+
3. Include every CVE; CVE tags do not count toward max_tags
|
|
222
|
+
4. Return tags in lowercase and keep them concise
|
|
227
223
|
|
|
228
224
|
Article:
|
|
229
225
|
{article_text}
|
|
230
226
|
|
|
231
227
|
Respond in JSON format:
|
|
232
228
|
{
|
|
233
|
-
"tags": [
|
|
234
|
-
|
|
229
|
+
"tags": [
|
|
230
|
+
{"tag": "tag1", "type": "NAMED_ENTITY", "category": "ORGANIZATION",
|
|
231
|
+
"reasoning": "Why this tag and category are relevant"}
|
|
232
|
+
]
|
|
235
233
|
}
|
|
236
234
|
```
|
|
237
235
|
|
|
@@ -241,7 +239,7 @@ Systemet extraherar sedan JSON från svar och tillämpar prioriteringslogiken.
|
|
|
241
239
|
|
|
242
240
|
Systemet identifierar domänspecifika entiteter genom:
|
|
243
241
|
|
|
244
|
-
1. **Regex-mönster**: CVE format (`CVE
|
|
242
|
+
1. **Regex-mönster**: CVE format (`CVE-[0-9]{4}-[0-9]{4,19}`)
|
|
245
243
|
2. **Nyckelordsmatchning**:
|
|
246
244
|
- Hot-aktörer: "APT", "group", "threat actor", "collective"
|
|
247
245
|
- Regioner: Länder, kontinenter, geografiska termer
|
|
@@ -363,7 +361,7 @@ similar_tags = tag_manager._find_similar_existing_tags(
|
|
|
363
361
|
Redigera regex-mönster och nyckelord i `tagging.py`:
|
|
364
362
|
|
|
365
363
|
```python
|
|
366
|
-
CVE_PATTERN = re.compile(r'CVE
|
|
364
|
+
CVE_PATTERN = re.compile(r'CVE-[0-9]{4}-[0-9]{4,19}', re.IGNORECASE)
|
|
367
365
|
THREAT_ACTOR_KEYWORDS = {'APT', 'group', ...} # Lägg till fler ord
|
|
368
366
|
```
|
|
369
367
|
|
|
@@ -32,9 +32,16 @@ dependencies = [
|
|
|
32
32
|
|
|
33
33
|
[project.optional-dependencies]
|
|
34
34
|
mongodb = ["pymongo>=4.10"]
|
|
35
|
+
ml = [
|
|
36
|
+
"pymongo>=4.10",
|
|
37
|
+
"joblib>=1.2",
|
|
38
|
+
"scikit-learn>=1.7,<1.8; python_version < '3.11'",
|
|
39
|
+
"scikit-learn>=1.7,<2; python_version >= '3.11'",
|
|
40
|
+
]
|
|
35
41
|
|
|
36
42
|
[project.scripts]
|
|
37
43
|
feedsummary-migrate-tinydb-mongodb = "feedsummary_core.persistence.migrate_tinydb_to_mongodb:main"
|
|
44
|
+
feedsummary-benchmark-tags = "feedsummary_core.tagging_ml.cli:main"
|
|
38
45
|
|
|
39
46
|
[tool.setuptools]
|
|
40
47
|
package-dir = {"" = "src"}
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '2.
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
21
|
+
__version__ = version = '2.3.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 3, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g6b2e06899'
|
{feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/persistence/MongoDBStore.py
RENAMED
|
@@ -34,6 +34,7 @@ from __future__ import annotations
|
|
|
34
34
|
import logging
|
|
35
35
|
import math
|
|
36
36
|
import time
|
|
37
|
+
from collections.abc import Iterator
|
|
37
38
|
from typing import Any, Dict, List, Optional, Tuple
|
|
38
39
|
|
|
39
40
|
from feedsummary_core.persistence.CleanUpPolicy import CleanupPolicy
|
|
@@ -106,6 +107,7 @@ class MongoDBStore:
|
|
|
106
107
|
*,
|
|
107
108
|
client: Any = None,
|
|
108
109
|
connect_timeout_ms: int = 5000,
|
|
110
|
+
initialize_schema: bool = True,
|
|
109
111
|
):
|
|
110
112
|
if not database or not str(database).strip():
|
|
111
113
|
raise ValueError("database must be a non-empty string")
|
|
@@ -122,7 +124,8 @@ class MongoDBStore:
|
|
|
122
124
|
|
|
123
125
|
self.client = client
|
|
124
126
|
self.db = client[self.database_name]
|
|
125
|
-
|
|
127
|
+
if initialize_schema:
|
|
128
|
+
self._init_db()
|
|
126
129
|
|
|
127
130
|
def close(self) -> None:
|
|
128
131
|
if self._owns_client and self.client is not None:
|
|
@@ -247,6 +250,16 @@ class MongoDBStore:
|
|
|
247
250
|
cursor = self.db.articles.find().sort("_mongo_sort_ts", ASCENDING).limit(max(0, int(limit)))
|
|
248
251
|
return [_public_doc(doc) for doc in cursor] # type: ignore[misc]
|
|
249
252
|
|
|
253
|
+
def iter_articles(self, limit: Optional[int] = None) -> Iterator[Dict[str, Any]]:
|
|
254
|
+
"""Yield every article oldest-first without the list API's default cap."""
|
|
255
|
+
cursor = self.db.articles.find().sort("_mongo_sort_ts", ASCENDING)
|
|
256
|
+
if limit is not None and int(limit) > 0:
|
|
257
|
+
cursor = cursor.limit(int(limit))
|
|
258
|
+
for doc in cursor:
|
|
259
|
+
article = _public_doc(doc)
|
|
260
|
+
if isinstance(article, dict):
|
|
261
|
+
yield article
|
|
262
|
+
|
|
250
263
|
def list_articles_by_filter(
|
|
251
264
|
self,
|
|
252
265
|
*,
|
|
@@ -466,6 +479,71 @@ class MongoDBStore:
|
|
|
466
479
|
def get_all_tags(self) -> List[Dict[str, Any]]:
|
|
467
480
|
return [self._tag_doc(doc) for doc in self.db.tags.find().sort("name", ASCENDING)] # type: ignore[misc]
|
|
468
481
|
|
|
482
|
+
def iter_articles_with_tags(
|
|
483
|
+
self,
|
|
484
|
+
*,
|
|
485
|
+
categories: Optional[List[str]] = None,
|
|
486
|
+
limit: Optional[int] = None,
|
|
487
|
+
) -> Iterator[Dict[str, Any]]:
|
|
488
|
+
"""Yield canonically tagged articles for read-only exports.
|
|
489
|
+
|
|
490
|
+
The export starts from ``article_tags`` so an article is included only
|
|
491
|
+
when a tagging pass produced at least one persisted association. The
|
|
492
|
+
optional category filter applies to the returned labels, not to article
|
|
493
|
+
eligibility; an article tagged only with excluded entity categories is
|
|
494
|
+
therefore returned with an empty ``tags`` list and can act as a negative
|
|
495
|
+
training example.
|
|
496
|
+
"""
|
|
497
|
+
pipeline: List[Dict[str, Any]] = [
|
|
498
|
+
{
|
|
499
|
+
"$group": {
|
|
500
|
+
"_id": "$article_id",
|
|
501
|
+
"tag_ids": {"$addToSet": "$tag_id"},
|
|
502
|
+
}
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
"$lookup": {
|
|
506
|
+
"from": "articles",
|
|
507
|
+
"localField": "_id",
|
|
508
|
+
"foreignField": "_id",
|
|
509
|
+
"as": "article_docs",
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
{"$unwind": "$article_docs"},
|
|
513
|
+
{
|
|
514
|
+
"$lookup": {
|
|
515
|
+
"from": "tags",
|
|
516
|
+
"localField": "tag_ids",
|
|
517
|
+
"foreignField": "_id",
|
|
518
|
+
"as": "tag_docs",
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
{"$sort": {"article_docs._mongo_sort_ts": ASCENDING, "_id": ASCENDING}},
|
|
522
|
+
]
|
|
523
|
+
if limit is not None and int(limit) > 0:
|
|
524
|
+
pipeline.append({"$limit": int(limit)})
|
|
525
|
+
|
|
526
|
+
allowed = {
|
|
527
|
+
str(category).strip().casefold()
|
|
528
|
+
for category in categories or []
|
|
529
|
+
if str(category).strip()
|
|
530
|
+
}
|
|
531
|
+
for row in self.db.article_tags.aggregate(pipeline):
|
|
532
|
+
article = _public_doc(row.get("article_docs"))
|
|
533
|
+
if not isinstance(article, dict):
|
|
534
|
+
continue
|
|
535
|
+
tags = []
|
|
536
|
+
for raw_tag in row.get("tag_docs") or []:
|
|
537
|
+
tag = self._tag_doc(raw_tag)
|
|
538
|
+
if not isinstance(tag, dict):
|
|
539
|
+
continue
|
|
540
|
+
category = str(tag.get("category") or "GENERAL").casefold()
|
|
541
|
+
if allowed and category not in allowed:
|
|
542
|
+
continue
|
|
543
|
+
tags.append(tag)
|
|
544
|
+
tags.sort(key=lambda tag: str(tag.get("name") or "").casefold())
|
|
545
|
+
yield {"article": article, "tags": tags}
|
|
546
|
+
|
|
469
547
|
def add_article_tags(self, article_id: str, tag_ids: List) -> None:
|
|
470
548
|
if not article_id or not tag_ids:
|
|
471
549
|
return
|
{feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/persistence/SqliteStore.py
RENAMED
|
@@ -36,6 +36,7 @@ import json
|
|
|
36
36
|
import logging
|
|
37
37
|
import sqlite3
|
|
38
38
|
import time
|
|
39
|
+
from collections.abc import Iterator
|
|
39
40
|
from pathlib import Path
|
|
40
41
|
from typing import Any, Dict, List, Optional, Tuple
|
|
41
42
|
|
|
@@ -434,6 +435,26 @@ class SqliteStore:
|
|
|
434
435
|
finally:
|
|
435
436
|
con.close()
|
|
436
437
|
|
|
438
|
+
def iter_articles(self, limit: Optional[int] = None) -> Iterator[Dict[str, Any]]:
|
|
439
|
+
"""Yield every article oldest-first without the list API's default cap."""
|
|
440
|
+
con = self._connect()
|
|
441
|
+
try:
|
|
442
|
+
query = """
|
|
443
|
+
SELECT doc_json
|
|
444
|
+
FROM articles
|
|
445
|
+
ORDER BY COALESCE(published_ts, fetched_at, 0) ASC
|
|
446
|
+
"""
|
|
447
|
+
params: Tuple[Any, ...] = ()
|
|
448
|
+
if limit is not None and int(limit) > 0:
|
|
449
|
+
query += " LIMIT ?"
|
|
450
|
+
params = (int(limit),)
|
|
451
|
+
for row in con.execute(query, params):
|
|
452
|
+
article = _json_loads(row["doc_json"]) or {}
|
|
453
|
+
if isinstance(article, dict):
|
|
454
|
+
yield article
|
|
455
|
+
finally:
|
|
456
|
+
con.close()
|
|
457
|
+
|
|
437
458
|
def list_articles_by_filter(
|
|
438
459
|
self,
|
|
439
460
|
*,
|
{feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/persistence/TinyDbStore.py
RENAMED
|
@@ -34,6 +34,7 @@ from __future__ import annotations
|
|
|
34
34
|
|
|
35
35
|
import logging
|
|
36
36
|
import time
|
|
37
|
+
from collections.abc import Iterator
|
|
37
38
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
38
39
|
|
|
39
40
|
from tinydb import Query, TinyDB
|
|
@@ -118,6 +119,19 @@ class TinyDBStore:
|
|
|
118
119
|
out.sort(key=lambda r: int(r.get("published_ts") or r.get("fetched_at") or 0))
|
|
119
120
|
return out[:limit]
|
|
120
121
|
|
|
122
|
+
def iter_articles(self, limit: Optional[int] = None) -> Iterator[Dict[str, Any]]:
|
|
123
|
+
"""Yield every article oldest-first without the list API's default cap."""
|
|
124
|
+
db = self._db()
|
|
125
|
+
try:
|
|
126
|
+
articles = [dict(row) for row in db.table("articles")]
|
|
127
|
+
finally:
|
|
128
|
+
db.close()
|
|
129
|
+
articles.sort(
|
|
130
|
+
key=lambda row: int(row.get("published_ts") or row.get("fetched_at") or 0)
|
|
131
|
+
)
|
|
132
|
+
maximum = int(limit) if limit is not None and int(limit) > 0 else None
|
|
133
|
+
yield from articles[:maximum]
|
|
134
|
+
|
|
121
135
|
def list_articles_by_filter(
|
|
122
136
|
self,
|
|
123
137
|
*,
|
{feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/persistence/__init__.py
RENAMED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
from __future__ import annotations
|
|
34
34
|
|
|
35
35
|
import os
|
|
36
|
+
from collections.abc import Iterator
|
|
36
37
|
from pathlib import Path
|
|
37
38
|
from typing import Any, Dict, List, Optional, Protocol
|
|
38
39
|
from feedsummary_core.persistence.CleanUpPolicy import CleanupPolicy
|
|
@@ -67,6 +68,8 @@ class NewsStore(Protocol):
|
|
|
67
68
|
|
|
68
69
|
def list_articles(self, limit: int = 2000) -> List[Dict[str, Any]]: ...
|
|
69
70
|
|
|
71
|
+
def iter_articles(self, limit: Optional[int] = None) -> Iterator[Dict[str, Any]]: ...
|
|
72
|
+
|
|
70
73
|
def list_articles_by_filter(
|
|
71
74
|
self,
|
|
72
75
|
*,
|
|
@@ -115,6 +118,13 @@ class NewsStore(Protocol):
|
|
|
115
118
|
|
|
116
119
|
def get_all_tags(self) -> List[Dict[str, Any]]: ...
|
|
117
120
|
|
|
121
|
+
def iter_articles_with_tags(
|
|
122
|
+
self,
|
|
123
|
+
*,
|
|
124
|
+
categories: Optional[List[str]] = None,
|
|
125
|
+
limit: Optional[int] = None,
|
|
126
|
+
): ...
|
|
127
|
+
|
|
118
128
|
def add_article_tags(self, article_id: str, tag_ids: List) -> None: ...
|
|
119
129
|
|
|
120
130
|
def add_tag_to_article(self, article_id: str, tag_id: int) -> bool: ...
|
|
@@ -220,6 +230,7 @@ def create_store(cfg: Dict[str, Any]) -> NewsStore:
|
|
|
220
230
|
database=cfg.get("database") or cfg.get("database_name") or "feedsummary",
|
|
221
231
|
client=cfg.get("client"),
|
|
222
232
|
connect_timeout_ms=int(cfg.get("connect_timeout_ms", 5000)),
|
|
233
|
+
initialize_schema=bool(cfg.get("initialize_schema", True)),
|
|
223
234
|
) # type: ignore
|
|
224
235
|
|
|
225
236
|
raise ValueError(f"Unsupported store provider: {provider}")
|