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.
Files changed (69) hide show
  1. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/.gitignore +3 -0
  2. {feedsummary_core-2.2.0/src/feedsummary_core.egg-info → feedsummary_core-2.3.0}/PKG-INFO +113 -4
  3. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/README.md +107 -3
  4. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/TAGGING_QUICKSTART.md +1 -1
  5. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/TAGGING_SYSTEM.md +25 -27
  6. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/pyproject.toml +7 -0
  7. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/_version.py +3 -3
  8. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/persistence/MongoDBStore.py +79 -1
  9. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/persistence/SqliteStore.py +21 -0
  10. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/persistence/TinyDbStore.py +14 -0
  11. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/persistence/__init__.py +11 -0
  12. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/batching.py +58 -6
  13. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/ingest.py +229 -18
  14. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/tagging.py +373 -47
  15. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/tagging_integration.py +144 -7
  16. feedsummary_core-2.3.0/src/feedsummary_core/tagging_ml/__init__.py +51 -0
  17. feedsummary_core-2.3.0/src/feedsummary_core/tagging_ml/benchmark.py +976 -0
  18. feedsummary_core-2.3.0/src/feedsummary_core/tagging_ml/cli.py +169 -0
  19. feedsummary_core-2.3.0/src/feedsummary_core/tagging_ml/embedding_sgd.py +511 -0
  20. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/tagging_rules.py +19 -1
  21. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0/src/feedsummary_core.egg-info}/PKG-INFO +113 -4
  22. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core.egg-info/SOURCES.txt +9 -0
  23. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core.egg-info/entry_points.txt +1 -0
  24. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core.egg-info/requires.txt +10 -0
  25. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core.egg-info/scm_file_list.json +9 -0
  26. feedsummary_core-2.3.0/src/feedsummary_core.egg-info/scm_version.json +8 -0
  27. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/tests/test_embedding_persistence.py +35 -1
  28. feedsummary_core-2.3.0/tests/test_embedding_sgd_tagging.py +237 -0
  29. feedsummary_core-2.3.0/tests/test_ingest_rss_only.py +98 -0
  30. feedsummary_core-2.3.0/tests/test_ingest_tls.py +193 -0
  31. feedsummary_core-2.3.0/tests/test_ml_tagging_integration.py +198 -0
  32. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/tests/test_mongodb_store.py +56 -0
  33. feedsummary_core-2.3.0/tests/test_tag_matching.py +247 -0
  34. feedsummary_core-2.3.0/tests/test_tagging_cve_category.py +317 -0
  35. feedsummary_core-2.3.0/tests/test_tagging_ml.py +283 -0
  36. feedsummary_core-2.2.0/src/feedsummary_core.egg-info/scm_version.json +0 -8
  37. feedsummary_core-2.2.0/tests/test_tag_matching.py +0 -119
  38. feedsummary_core-2.2.0/tests/test_tagging_cve_category.py +0 -131
  39. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/.github/workflows/release.yaml +0 -0
  40. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/LICENSE +0 -0
  41. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/TAGGING_INTEGRATION_GUIDE.md +0 -0
  42. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/TAG_BASED_SUMMARY.md +0 -0
  43. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/examples/tagging_examples.py +0 -0
  44. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/setup.cfg +0 -0
  45. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/__init__.py +0 -0
  46. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/llm_client/__init__.py +0 -0
  47. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/llm_client/fallback_client.py +0 -0
  48. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/llm_client/ollama_cloud.py +0 -0
  49. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/llm_client/ollama_local.py +0 -0
  50. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/persistence/CleanUpPolicy.py +0 -0
  51. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/persistence/helpers.py +0 -0
  52. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/persistence/migrate_tinydb_to_mongodb.py +0 -0
  53. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/prompts/loader.py +0 -0
  54. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/__init__.py +0 -0
  55. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/chat.py +0 -0
  56. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/helpers.py +0 -0
  57. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/main.py +0 -0
  58. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/prompt_lab.py +0 -0
  59. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/prompt_replay.py +0 -0
  60. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/summarizer.py +0 -0
  61. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core/summarizer/token_budget.py +0 -0
  62. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core.egg-info/dependency_links.txt +0 -0
  63. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/src/feedsummary_core.egg-info/top_level.txt +0 -0
  64. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/tests/test_domain_extraction.py +0 -0
  65. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/tests/test_embedding_routing.py +0 -0
  66. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/tests/test_similarity_batching.py +0 -0
  67. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/tests/test_tag_based_summary.py +0 -0
  68. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/tests/test_tag_similarity_consistency.py +0 -0
  69. {feedsummary_core-2.2.0 → feedsummary_core-2.3.0}/tests/test_tinydb_mongodb_migration.py +0 -0
@@ -154,6 +154,9 @@ venv.bak/
154
154
  # mkdocs documentation
155
155
  /site
156
156
 
157
+ # Local machine-learning benchmark outputs
158
+ /artifacts/tag-benchmark/
159
+
157
160
  # mypy
158
161
  .mypy_cache/
159
162
  .dmypy.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: feedsummary-core
3
- Version: 2.2.0
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
- - url: "https://example.com/feed.xml"
192
- lookback: "48h"
193
- enabled: true
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
- - url: "https://example.com/feed.xml"
166
- lookback: "48h"
167
- enabled: true
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-\d{4}-\d{4,5}|GHSA-\w{4}-\w{4}-\w{4}')
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
- ### GENERAL (Standard)
90
- - Normala ämnestaggar
91
- - Prioriteras från befintliga taggar
92
- - Får inte skapas nya automatiskt (om de redan finns)
93
-
94
- ### DOMAIN_ENTITY (Domänspecifik)
95
- Undantag där nya taggar ALLTID tillåts:
96
- - **CVE:er**: `CVE-2024-1234` mönster
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. Extrahera kandidattaggar från artikel (via LLM)
105
- 2. För varje kandidattagg:
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 domänspecifik entitet → skapa ny DOMAIN_ENTITY tagg
109
- d. Annars → hoppa över (föredra allmän framför specifik)
110
- 3. Lagra valda taggar i databasen
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. Prefer existing, general tags over creating new, specific tags
222
- 2. CVE numbers (e.g., CVE-2024-1234), threat actors, regions, and
223
- vulnerability names are EXCEPTIONS - these should be new tags
224
- 3. Return tags in lowercase
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": ["tag1", "tag2", "tag3"],
234
- "reasoning": "Brief explanation of why these tags were chosen"
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-\d{4}-\d{4,5}`)
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-\d{4}-\d{4,5}', re.IGNORECASE)
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.2.0'
22
- __version_tuple__ = version_tuple = (2, 2, 0)
21
+ __version__ = version = '2.3.0'
22
+ __version_tuple__ = version_tuple = (2, 3, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g03a56f8f4'
24
+ __commit_id__ = commit_id = 'g6b2e06899'
@@ -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
- self._init_db()
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
@@ -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
  *,
@@ -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
  *,
@@ -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}")