feedsummary-core 2.2.0__tar.gz → 2.2.1__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 (58) hide show
  1. {feedsummary_core-2.2.0/src/feedsummary_core.egg-info → feedsummary_core-2.2.1}/PKG-INFO +1 -1
  2. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/TAGGING_QUICKSTART.md +1 -1
  3. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/TAGGING_SYSTEM.md +25 -27
  4. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/_version.py +3 -3
  5. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/tagging.py +193 -25
  6. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/tagging_integration.py +3 -3
  7. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/tagging_rules.py +19 -1
  8. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1/src/feedsummary_core.egg-info}/PKG-INFO +1 -1
  9. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core.egg-info/scm_version.json +2 -2
  10. feedsummary_core-2.2.1/tests/test_tagging_cve_category.py +274 -0
  11. feedsummary_core-2.2.0/tests/test_tagging_cve_category.py +0 -131
  12. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/.github/workflows/release.yaml +0 -0
  13. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/.gitignore +0 -0
  14. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/LICENSE +0 -0
  15. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/README.md +0 -0
  16. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/TAGGING_INTEGRATION_GUIDE.md +0 -0
  17. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/TAG_BASED_SUMMARY.md +0 -0
  18. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/examples/tagging_examples.py +0 -0
  19. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/pyproject.toml +0 -0
  20. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/setup.cfg +0 -0
  21. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/__init__.py +0 -0
  22. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/llm_client/__init__.py +0 -0
  23. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/llm_client/fallback_client.py +0 -0
  24. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/llm_client/ollama_cloud.py +0 -0
  25. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/llm_client/ollama_local.py +0 -0
  26. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/persistence/CleanUpPolicy.py +0 -0
  27. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/persistence/MongoDBStore.py +0 -0
  28. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/persistence/SqliteStore.py +0 -0
  29. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/persistence/TinyDbStore.py +0 -0
  30. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/persistence/__init__.py +0 -0
  31. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/persistence/helpers.py +0 -0
  32. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/persistence/migrate_tinydb_to_mongodb.py +0 -0
  33. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/prompts/loader.py +0 -0
  34. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/__init__.py +0 -0
  35. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/batching.py +0 -0
  36. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/chat.py +0 -0
  37. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/helpers.py +0 -0
  38. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/ingest.py +0 -0
  39. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/main.py +0 -0
  40. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/prompt_lab.py +0 -0
  41. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/prompt_replay.py +0 -0
  42. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/summarizer.py +0 -0
  43. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/token_budget.py +0 -0
  44. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core.egg-info/SOURCES.txt +0 -0
  45. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core.egg-info/dependency_links.txt +0 -0
  46. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core.egg-info/entry_points.txt +0 -0
  47. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core.egg-info/requires.txt +0 -0
  48. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core.egg-info/scm_file_list.json +0 -0
  49. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core.egg-info/top_level.txt +0 -0
  50. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/tests/test_domain_extraction.py +0 -0
  51. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/tests/test_embedding_persistence.py +0 -0
  52. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/tests/test_embedding_routing.py +0 -0
  53. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/tests/test_mongodb_store.py +0 -0
  54. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/tests/test_similarity_batching.py +0 -0
  55. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/tests/test_tag_based_summary.py +0 -0
  56. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/tests/test_tag_matching.py +0 -0
  57. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/tests/test_tag_similarity_consistency.py +0 -0
  58. {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/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.2.0
3
+ Version: 2.2.1
4
4
  Summary: Core library for FeedSummary.
5
5
  Author: Martin Vesterlund
6
6
  License-Expression: BSD-3-Clause
@@ -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
 
@@ -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.2.1'
22
+ __version_tuple__ = version_tuple = (2, 2, 1)
23
23
 
24
- __commit_id__ = commit_id = 'g03a56f8f4'
24
+ __commit_id__ = commit_id = 'g4ab927c63'
@@ -64,6 +64,7 @@ from feedsummary_core.summarizer.batching import (
64
64
  from feedsummary_core.tagging_rules import (
65
65
  CVE_PATTERN,
66
66
  VULNERABILITY_TAG_CATEGORY,
67
+ extract_cve_ids,
67
68
  is_cve_tag,
68
69
  )
69
70
 
@@ -164,7 +165,7 @@ class TagManager:
164
165
 
165
166
  Args:
166
167
  name: Tag name (normalized to lowercase)
167
- category: Tag category (GENERAL, DOMAIN_ENTITY, or VULNERABILITY)
168
+ category: Name of a database-defined tag category
168
169
  description: Optional description
169
170
 
170
171
  Returns:
@@ -182,13 +183,16 @@ class TagManager:
182
183
  if is_cve_tag(name):
183
184
  category = self.TAG_CATEGORY_VULNERABILITY
184
185
 
185
- # Validate category
186
- if category not in (
187
- self.TAG_CATEGORY_GENERAL,
188
- self.TAG_CATEGORY_DOMAIN_ENTITY,
189
- self.TAG_CATEGORY_VULNERABILITY,
190
- ):
191
- category = self.TAG_CATEGORY_GENERAL
186
+ # Non-CVE categories must resolve to a category defined by the active
187
+ # database. Stores without category support retain the legacy defaults.
188
+ if not is_cve_tag(name):
189
+ resolved_category = self._resolve_tag_category(category)
190
+ if resolved_category is None:
191
+ logger.debug(
192
+ f"[TagValidate] Rejected tag '{name}': unknown category {category!r}"
193
+ )
194
+ return None
195
+ category = resolved_category
192
196
 
193
197
  # Validate tag name length (max 2 words for GENERAL, unlimited otherwise)
194
198
  if not self._is_valid_tag_name(name, category):
@@ -236,6 +240,57 @@ class TagManager:
236
240
  logger.error(f"Error getting all tags: {e}")
237
241
  return []
238
242
 
243
+ def get_all_categories(self) -> List[Dict[str, Any]]:
244
+ """Get all tag categories defined by the active store."""
245
+ try:
246
+ fn = getattr(self.store, "get_all_categories", None)
247
+ if callable(fn):
248
+ categories = fn()
249
+ if isinstance(categories, list):
250
+ return [category for category in categories if isinstance(category, dict)]
251
+ except Exception as e:
252
+ logger.error(f"Error getting tag categories: {e}")
253
+ return []
254
+
255
+ def _resolve_tag_category(
256
+ self,
257
+ proposed_category: Any,
258
+ fallback_category: str = TAG_CATEGORY_GENERAL,
259
+ categories: Optional[List[Dict[str, Any]]] = None,
260
+ ) -> Optional[str]:
261
+ """Resolve a proposed category to its canonical database-defined name."""
262
+ available = self.get_all_categories() if categories is None else categories
263
+ if not available:
264
+ legacy_categories = {
265
+ self.TAG_CATEGORY_GENERAL,
266
+ self.TAG_CATEGORY_DOMAIN_ENTITY,
267
+ self.TAG_CATEGORY_VULNERABILITY,
268
+ }
269
+ proposed = str(proposed_category or "").strip().upper()
270
+ if proposed in legacy_categories:
271
+ return proposed
272
+ if fallback_category in legacy_categories:
273
+ return fallback_category
274
+ return self.TAG_CATEGORY_GENERAL
275
+
276
+ category_names = {
277
+ str(category.get("name") or "").strip().casefold():
278
+ str(category.get("name") or "").strip()
279
+ for category in available
280
+ if str(category.get("name") or "").strip()
281
+ }
282
+ for candidate in (proposed_category, fallback_category, self.TAG_CATEGORY_GENERAL):
283
+ normalized = str(candidate or "").strip().casefold()
284
+ if normalized in category_names:
285
+ return category_names[normalized]
286
+
287
+ logger.warning(
288
+ "No database-defined tag category matches proposed=%r or fallback=%r",
289
+ proposed_category,
290
+ fallback_category,
291
+ )
292
+ return None
293
+
239
294
  def _ensure_cve_category(self, tag: Dict[str, Any]) -> Dict[str, Any]:
240
295
  """Persist and return the canonical category for an existing CVE tag."""
241
296
  name = str(tag.get("name") or "").strip()
@@ -883,6 +938,13 @@ class TagManager:
883
938
  """
884
939
  selected_tags: List[Dict[str, Any]] = []
885
940
  processed_tags: Set[str] = set()
941
+ available_categories = self.get_all_categories()
942
+ available_category_names = {
943
+ str(category.get("name") or "").strip().casefold():
944
+ str(category.get("name") or "").strip()
945
+ for category in available_categories
946
+ if str(category.get("name") or "").strip()
947
+ }
886
948
 
887
949
  for candidate in candidate_tags:
888
950
  if not candidate:
@@ -892,10 +954,12 @@ class TagManager:
892
954
  if isinstance(candidate, dict):
893
955
  tag_name = candidate.get("name", "").strip().lower()
894
956
  tag_type = candidate.get("type", "CATEGORY").upper()
957
+ proposed_category = candidate.get("category")
895
958
  tag_reasoning = candidate.get("reasoning", "")
896
959
  else:
897
960
  tag_name = str(candidate).strip().lower()
898
961
  tag_type = "CATEGORY" # Default for backward compatibility
962
+ proposed_category = None
899
963
  tag_reasoning = ""
900
964
 
901
965
  if not tag_name or tag_name in processed_tags:
@@ -915,6 +979,19 @@ class TagManager:
915
979
  elif allow_new_tags:
916
980
  # Decision logic based on tag type
917
981
  should_create = False
982
+ default_category = (
983
+ self.TAG_CATEGORY_DOMAIN_ENTITY
984
+ if tag_type == "NAMED_ENTITY"
985
+ else self.TAG_CATEGORY_GENERAL
986
+ )
987
+ explicit_category = available_category_names.get(
988
+ str(proposed_category or "").strip().casefold()
989
+ )
990
+ resolved_category = self._resolve_tag_category(
991
+ proposed_category,
992
+ fallback_category=default_category,
993
+ categories=available_categories,
994
+ )
918
995
 
919
996
  if tag_type == "NAMED_ENTITY":
920
997
  # NAMED_ENTITY tags (organizations, groups, actors) are always created
@@ -923,17 +1000,24 @@ class TagManager:
923
1000
  elif tag_type == "CATEGORY":
924
1001
  # CATEGORY tags only created if they match our keyword heuristics
925
1002
  should_create = self._is_relevant_new_tag(tag_name)
1003
+
1004
+ # An explicit valid database category is enough evidence to create
1005
+ # a new tag even if the legacy keyword heuristic does not know it.
1006
+ if explicit_category:
1007
+ should_create = True
926
1008
 
927
1009
  if should_create:
928
1010
  # Validate tag name (max 2 words for GENERAL, unlimited otherwise)
929
1011
  if is_cve_tag(tag_name):
930
1012
  tag_category = self.TAG_CATEGORY_VULNERABILITY
931
1013
  else:
932
- tag_category = (
933
- self.TAG_CATEGORY_DOMAIN_ENTITY
934
- if tag_type == "NAMED_ENTITY"
935
- else self.TAG_CATEGORY_GENERAL
1014
+ tag_category = resolved_category
1015
+
1016
+ if tag_category is None:
1017
+ logger.debug(
1018
+ f"[TagSelect] Rejected tag '{tag_name}': no valid database category"
936
1019
  )
1020
+ continue
937
1021
 
938
1022
  if not self._is_valid_tag_name(tag_name, tag_category):
939
1023
  logger.debug(f"[TagSelect] Rejected tag '{tag_name}': exceeds word limit for {tag_type}")
@@ -996,8 +1080,8 @@ class TagManager:
996
1080
  Expects JSON format with per-tag reasoning:
997
1081
  {
998
1082
  "tags": [
999
- {"tag": "tag1", "type": "NAMED_ENTITY", "reasoning": "Why this tag..."},
1000
- {"tag": "tag2", "type": "CATEGORY", "reasoning": "Why this tag..."},
1083
+ {"tag": "tag1", "type": "NAMED_ENTITY", "category": "ORGANIZATION", "reasoning": "Why this tag..."},
1084
+ {"tag": "tag2", "type": "CATEGORY", "category": "THREAT", "reasoning": "Why this tag..."},
1001
1085
  ...
1002
1086
  ]
1003
1087
  }
@@ -1022,7 +1106,8 @@ class TagManager:
1022
1106
  response: LLM response text
1023
1107
 
1024
1108
  Returns:
1025
- List of tags as dicts with 'name', 'type', and optional 'reasoning' keys.
1109
+ List of tags as dicts with 'name', 'type', 'category', and optional
1110
+ 'reasoning' keys.
1026
1111
  If reasoning is not provided, defaults to empty string.
1027
1112
  """
1028
1113
  if not response or not isinstance(response, str):
@@ -1053,11 +1138,13 @@ class TagManager:
1053
1138
  if isinstance(obj, dict):
1054
1139
  tag_name = obj.get("tag") or obj.get("name")
1055
1140
  tag_type = obj.get("type", "CATEGORY")
1141
+ tag_category = obj.get("category")
1056
1142
  tag_reasoning = obj.get("reasoning", "")
1057
1143
  if tag_name:
1058
1144
  tags.append({
1059
1145
  "name": str(tag_name).strip().lower(),
1060
1146
  "type": tag_type.upper() if tag_type else "CATEGORY",
1147
+ "category": str(tag_category).strip() if tag_category else "",
1061
1148
  "reasoning": str(tag_reasoning).strip() if tag_reasoning else ""
1062
1149
  })
1063
1150
  except Exception:
@@ -1094,11 +1181,13 @@ class TagManager:
1094
1181
  if isinstance(t, dict):
1095
1182
  tag_name = t.get("tag") or t.get("name")
1096
1183
  tag_type = t.get("type", "CATEGORY")
1184
+ tag_category = t.get("category")
1097
1185
  tag_reasoning = t.get("reasoning", "")
1098
1186
  if tag_name:
1099
1187
  tags.append({
1100
1188
  "name": str(tag_name).strip().lower(),
1101
1189
  "type": tag_type.upper() if tag_type else "CATEGORY",
1190
+ "category": str(tag_category).strip() if tag_category else "",
1102
1191
  "reasoning": str(tag_reasoning).strip() if tag_reasoning else ""
1103
1192
  })
1104
1193
  elif isinstance(t, str):
@@ -1129,7 +1218,7 @@ class TagManager:
1129
1218
  llm_client: LLM client for generating tags
1130
1219
  article: Article dict with 'title', 'content', etc.
1131
1220
  config: Configuration dict
1132
- max_tags: Maximum number of tags to select
1221
+ max_tags: Maximum number of non-CVE tags to select
1133
1222
 
1134
1223
  Returns:
1135
1224
  List of selected tags
@@ -1146,8 +1235,26 @@ class TagManager:
1146
1235
  if not text_context:
1147
1236
  return []
1148
1237
 
1238
+ # CVE identifiers are deterministic domain entities. Extract them directly
1239
+ # instead of relying on the LLM to include every identifier in its response.
1240
+ cve_candidates = [
1241
+ {
1242
+ "name": cve_id,
1243
+ "type": "NAMED_ENTITY",
1244
+ "category": self.TAG_CATEGORY_VULNERABILITY,
1245
+ "reasoning": "CVE identifier mentioned explicitly in the article.",
1246
+ }
1247
+ for cve_id in extract_cve_ids(
1248
+ " ".join(part for part in (title, content, summary) if part)
1249
+ )
1250
+ ]
1251
+
1149
1252
  # Prepare prompt for tag generation
1150
- prompt = self._build_tagging_prompt(text_context, max_tags)
1253
+ prompt = self._build_tagging_prompt(
1254
+ text_context,
1255
+ max_tags,
1256
+ categories=self.get_all_categories(),
1257
+ )
1151
1258
 
1152
1259
  try:
1153
1260
  # Use chat() method instead of generate() for compatibility with FallbackLLMClient
@@ -1156,26 +1263,81 @@ class TagManager:
1156
1263
  temperature=0.3
1157
1264
  )
1158
1265
  candidate_tags = self.extract_tags_from_llm_response(response)
1266
+ except Exception as e:
1267
+ logger.error(f"Error generating LLM tags: {e}")
1268
+ candidate_tags = []
1159
1269
 
1160
- # Select best tags using priority logic
1270
+ try:
1271
+ # CVEs are prepended so all directly extracted identifiers are processed
1272
+ # even when the LLM returns more candidates than the regular-tag budget.
1273
+ regular_limit = max(0, max_tags)
1274
+ regular_candidates = [
1275
+ candidate
1276
+ for candidate in candidate_tags
1277
+ if not is_cve_tag(candidate.get("name"))
1278
+ ]
1161
1279
  selected = await self.select_tags_for_article_async(
1162
1280
  article_id=article.get("id", ""),
1163
- candidate_tags=candidate_tags[:max_tags * 2], # Get more than needed
1281
+ candidate_tags=cve_candidates + regular_candidates[:regular_limit * 2],
1164
1282
  allow_new_tags=True,
1165
1283
  )
1166
1284
 
1167
- return selected[:max_tags] # Limit to max_tags
1285
+ # Preserve selection order while applying the maximum only to non-CVE
1286
+ # tags. Every CVE mentioned in the article remains in the result.
1287
+ limited: List[Dict[str, Any]] = []
1288
+ regular_count = 0
1289
+ for tag in selected:
1290
+ if is_cve_tag(tag.get("name")):
1291
+ limited.append(tag)
1292
+ elif regular_count < regular_limit:
1293
+ limited.append(tag)
1294
+ regular_count += 1
1295
+ return limited
1168
1296
  except Exception as e:
1169
1297
  logger.error(f"Error generating tags: {e}")
1170
1298
  return []
1171
1299
 
1172
- def _build_tagging_prompt(self, article_text: str, max_tags: int = 5) -> str:
1300
+ def _build_tagging_prompt(
1301
+ self,
1302
+ article_text: str,
1303
+ max_tags: int = 5,
1304
+ categories: Optional[List[Dict[str, Any]]] = None,
1305
+ ) -> str:
1173
1306
  """Build a prompt for the LLM to generate tags."""
1174
1307
  # Truncate article text if too long
1175
1308
  if len(article_text) > 2000:
1176
1309
  article_text = article_text[:2000] + "..."
1177
1310
 
1178
- return f"""Analyze the following article and extract up to {max_tags} relevant tags.
1311
+ category_lines = []
1312
+ category_names = []
1313
+ for category in categories or []:
1314
+ name = str(category.get("name") or "").strip()
1315
+ if not name:
1316
+ continue
1317
+ category_names.append(name)
1318
+ label = str(category.get("label") or "").strip()
1319
+ description = str(category.get("description") or "").strip()
1320
+ details = ": ".join(part for part in (label, description) if part)
1321
+ category_lines.append(f"- {name}" + (f": {details}" if details else ""))
1322
+ if not category_names:
1323
+ category_names = ["GENERAL", "DOMAIN_ENTITY", "VULNERABILITY"]
1324
+ category_lines = [f"- {name}" for name in category_names]
1325
+ category_list = "\n".join(category_lines)
1326
+ category_lookup = {name.casefold(): name for name in category_names}
1327
+
1328
+ def example_category(*preferred: str) -> str:
1329
+ for name in preferred:
1330
+ if name.casefold() in category_lookup:
1331
+ return category_lookup[name.casefold()]
1332
+ return category_names[0]
1333
+
1334
+ entity_category = example_category("ORGANIZATION", "DOMAIN_ENTITY", "GENERAL")
1335
+ topic_category = example_category("THREAT", "GENERAL")
1336
+ product_category = example_category("PRODUCT", "DOMAIN_ENTITY", "GENERAL")
1337
+
1338
+ return f"""Analyze the following article and extract up to {max_tags} relevant non-CVE tags.
1339
+ Also include every CVE identifier mentioned in the article.
1340
+ CVE identifiers do not count toward this limit.
1179
1341
 
1180
1342
  CLASSIFICATION RULES:
1181
1343
  Tags should be classified as one of two types:
@@ -1196,10 +1358,16 @@ Tags should be classified as one of two types:
1196
1358
  - Security concepts (zero-day, vulnerability, authentication)
1197
1359
  - Any conceptual or descriptive tags
1198
1360
 
1361
+ AVAILABLE DATABASE CATEGORIES:
1362
+ {category_list}
1363
+
1199
1364
  IMPORTANT:
1200
1365
  - Return tags in lowercase
1201
1366
  - Keep tags concise (1-3 words max)
1202
1367
  - Focus on the main topics and entities mentioned
1368
+ - Include every CVE identifier, even when this makes the total exceed {max_tags} tags
1369
+ - Set "category" to exactly one name from AVAILABLE DATABASE CATEGORIES
1370
+ - Choose the most specific suitable database category; use GENERAL when none is suitable
1203
1371
  - For NAMED_ENTITY tags: include ANY organization/group/actor/product name mentioned, even if uncommon
1204
1372
  - For CATEGORY tags: only include if they are central to the article
1205
1373
  - Provide a brief explanation for each tag (1-2 sentences max)
@@ -1210,9 +1378,9 @@ Article:
1210
1378
  Respond in JSON format:
1211
1379
  {{
1212
1380
  "tags": [
1213
- {{"tag": "tag1", "type": "NAMED_ENTITY", "reasoning": "Brief explanation why this tag is relevant"}},
1214
- {{"tag": "tag2", "type": "CATEGORY", "reasoning": "Brief explanation why this tag is relevant"}},
1215
- {{"tag": "tag3", "type": "NAMED_ENTITY", "reasoning": "Brief explanation why this tag is relevant"}}
1381
+ {{"tag": "tag1", "type": "NAMED_ENTITY", "category": "{entity_category}", "reasoning": "Brief explanation why this tag is relevant"}},
1382
+ {{"tag": "tag2", "type": "CATEGORY", "category": "{topic_category}", "reasoning": "Brief explanation why this tag is relevant"}},
1383
+ {{"tag": "tag3", "type": "NAMED_ENTITY", "category": "{product_category}", "reasoning": "Brief explanation why this tag is relevant"}}
1216
1384
  ]
1217
1385
  }}"""
1218
1386
 
@@ -66,7 +66,7 @@ async def tag_articles(
66
66
  llm_client: LLM client for generating tags and embeddings
67
67
  article_ids: List of article IDs to tag
68
68
  config: Configuration dictionary
69
- max_tags_per_article: Maximum tags per article
69
+ max_tags_per_article: Maximum non-CVE tags per article
70
70
  skip_if_already_tagged: If True, skip articles that already have tags
71
71
  enable_embedding_matching: If True, use embedding-based tag matching when available
72
72
 
@@ -187,7 +187,7 @@ async def tag_article(
187
187
  llm_client: LLM client
188
188
  article_id: Article ID to tag
189
189
  config: Configuration
190
- max_tags: Maximum tags
190
+ max_tags: Maximum non-CVE tags
191
191
 
192
192
  Returns:
193
193
  List of assigned tags
@@ -320,7 +320,7 @@ async def tag_articles_safe(
320
320
  article_ids: List of article IDs to tag
321
321
  config: Configuration dictionary
322
322
  job_id: Optional job ID for logging
323
- max_tags_per_article: Maximum tags per article
323
+ max_tags_per_article: Maximum non-CVE tags per article
324
324
 
325
325
  Returns:
326
326
  Number of articles successfully tagged
@@ -37,7 +37,25 @@ from typing import Any
37
37
 
38
38
 
39
39
  VULNERABILITY_TAG_CATEGORY = "VULNERABILITY"
40
- CVE_PATTERN = re.compile(r"CVE-\d{4}-\d{4,}", re.IGNORECASE)
40
+ CVE_PATTERN = re.compile(
41
+ r"(?<!\w)CVE-[0-9]{4}-[0-9]{4,19}(?!\w)",
42
+ re.IGNORECASE,
43
+ )
44
+
45
+
46
+ def extract_cve_ids(text: Any) -> list[str]:
47
+ """Extract unique CVE identifiers in canonical uppercase form."""
48
+ if not isinstance(text, str):
49
+ return []
50
+
51
+ cve_ids: list[str] = []
52
+ seen: set[str] = set()
53
+ for match in CVE_PATTERN.finditer(text):
54
+ cve_id = match.group(0).upper()
55
+ if cve_id not in seen:
56
+ seen.add(cve_id)
57
+ cve_ids.append(cve_id)
58
+ return cve_ids
41
59
 
42
60
 
43
61
  def is_cve_tag(name: Any) -> bool:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: feedsummary-core
3
- Version: 2.2.0
3
+ Version: 2.2.1
4
4
  Summary: Core library for FeedSummary.
5
5
  Author: Martin Vesterlund
6
6
  License-Expression: BSD-3-Clause
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "2.2.0",
2
+ "tag": "2.2.1",
3
3
  "distance": 0,
4
- "node": "g03a56f8f464f99830bdc5eb121fc20a457cc7b78",
4
+ "node": "g4ab927c63858ac0a23fc65c3ebd727d0cbe7884c",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-08-21"
@@ -0,0 +1,274 @@
1
+ import asyncio
2
+ import json
3
+ import unittest
4
+ from pathlib import Path
5
+ from tempfile import TemporaryDirectory
6
+
7
+ from feedsummary_core.persistence.SqliteStore import SqliteStore
8
+ from feedsummary_core.persistence.TinyDbStore import TinyDBStore
9
+ from feedsummary_core.summarizer.tagging import TagManager
10
+ from feedsummary_core.tagging_rules import extract_cve_ids
11
+
12
+
13
+ class MemoryTagStore:
14
+ def __init__(self, categories=None):
15
+ self.tags = []
16
+ self.categories = categories or []
17
+
18
+ def add_tag(self, name, category="GENERAL", description=None):
19
+ existing = self.get_tag_by_name(name)
20
+ if existing:
21
+ return existing["id"]
22
+ tag = {
23
+ "id": len(self.tags) + 1,
24
+ "name": name,
25
+ "category": category,
26
+ "description": description,
27
+ }
28
+ self.tags.append(tag)
29
+ return tag["id"]
30
+
31
+ def get_tag_by_name(self, name):
32
+ normalized = name.strip().lower()
33
+ return next(
34
+ (tag.copy() for tag in self.tags if tag["name"].lower() == normalized),
35
+ None,
36
+ )
37
+
38
+ def get_all_tags(self):
39
+ return [tag.copy() for tag in self.tags]
40
+
41
+ def get_all_categories(self):
42
+ return [category.copy() for category in self.categories]
43
+
44
+ def update_tag(self, tag_id, category=None, **_kwargs):
45
+ tag = next(tag for tag in self.tags if tag["id"] == tag_id)
46
+ if category is not None:
47
+ tag["category"] = category
48
+ return tag.copy()
49
+
50
+
51
+ class FakeTaggingClient:
52
+ def __init__(self, tags=None, error=None):
53
+ self.tags = tags
54
+ self.error = error
55
+ self.messages = []
56
+
57
+ async def chat(self, messages, temperature=0.3):
58
+ self.messages = messages
59
+ if self.error:
60
+ raise self.error
61
+ return json.dumps({"tags": self.tags})
62
+
63
+
64
+ class CveCategoryTests(unittest.TestCase):
65
+ def setUp(self):
66
+ self.store = MemoryTagStore()
67
+ self.manager = TagManager(self.store)
68
+
69
+ def test_add_tag_assigns_vulnerability_to_uppercase_cve(self):
70
+ tag_id = self.manager.add_tag("CVE-2026-12345", category="DOMAIN_ENTITY")
71
+
72
+ tag = self.store.get_tag_by_name("CVE-2026-12345")
73
+ self.assertEqual(tag_id, tag["id"])
74
+ self.assertEqual("VULNERABILITY", tag["category"])
75
+
76
+ def test_select_tag_assigns_vulnerability_to_lowercase_six_digit_cve(self):
77
+ selected = self.manager.select_tags_for_article(
78
+ "article-1",
79
+ [{"name": "cve-2026-123456", "type": "NAMED_ENTITY"}],
80
+ )
81
+
82
+ self.assertEqual(1, len(selected))
83
+ self.assertEqual("cve-2026-123456", selected[0]["name"])
84
+ self.assertEqual("VULNERABILITY", selected[0]["category"])
85
+
86
+ def test_existing_cve_is_moved_to_vulnerability(self):
87
+ tag_id = self.store.add_tag("cve-2025-1234", category="DOMAIN_ENTITY")
88
+
89
+ selected = self.manager.select_tags_for_article(
90
+ "article-1",
91
+ ["CVE-2025-1234"],
92
+ )
93
+
94
+ self.assertEqual("VULNERABILITY", selected[0]["category"])
95
+ self.assertEqual(
96
+ "VULNERABILITY",
97
+ self.store.get_tag_by_name("cve-2025-1234")["category"],
98
+ )
99
+ self.assertEqual(tag_id, selected[0]["id"])
100
+
101
+ def test_distinct_cves_are_not_fuzzy_matched_to_each_other(self):
102
+ first = self.manager.select_tags_for_article(
103
+ "article-1",
104
+ ["CVE-2026-12345"],
105
+ )
106
+ second = self.manager.select_tags_for_article(
107
+ "article-2",
108
+ ["CVE-2026-12346"],
109
+ )
110
+
111
+ self.assertNotEqual(first[0]["id"], second[0]["id"])
112
+ self.assertEqual(2, len(self.store.tags))
113
+ self.assertTrue(
114
+ all(tag["category"] == "VULNERABILITY" for tag in self.store.tags)
115
+ )
116
+
117
+ def test_non_cve_tag_keeps_requested_category(self):
118
+ self.manager.add_tag("cve-2026-123", category="DOMAIN_ENTITY")
119
+
120
+ tag = self.store.get_tag_by_name("cve-2026-123")
121
+ self.assertEqual("DOMAIN_ENTITY", tag["category"])
122
+
123
+ def test_extracts_only_complete_cves_with_four_to_nineteen_digits(self):
124
+ nineteen_digits = "1234567890123456789"
125
+ text = (
126
+ "CVE-2024-1234, cve-2025-123456 and "
127
+ f"CVE-2026-{nineteen_digits}; repeated CVE-2024-1234. "
128
+ "Ignore CVE-2024-123, CVE-2024-12345678901234567890 and "
129
+ "prefixCVE-2024-9999suffix."
130
+ )
131
+
132
+ self.assertEqual(
133
+ [
134
+ "CVE-2024-1234",
135
+ "CVE-2025-123456",
136
+ f"CVE-2026-{nineteen_digits}",
137
+ ],
138
+ extract_cve_ids(text),
139
+ )
140
+
141
+ def test_generated_cves_do_not_count_toward_regular_tag_limit(self):
142
+ client = FakeTaggingClient(
143
+ [
144
+ {"tag": "CVE-2025-1234", "type": "NAMED_ENTITY"},
145
+ {"tag": "CVE-2026-123456", "type": "NAMED_ENTITY"},
146
+ {"tag": "CVE-2025-1234", "type": "NAMED_ENTITY"},
147
+ {"tag": "Acme", "type": "NAMED_ENTITY"},
148
+ {"tag": "Example Product", "type": "NAMED_ENTITY"},
149
+ {"tag": "Third Entity", "type": "NAMED_ENTITY"},
150
+ ]
151
+ )
152
+ article = {
153
+ "id": "article-1",
154
+ "title": "CVE-2025-1234 fixed by Acme",
155
+ "content": "The update also fixes cve-2026-123456.",
156
+ "summary": "CVE-2025-1234 is mentioned again.",
157
+ }
158
+
159
+ selected = asyncio.run(
160
+ self.manager.generate_tags_for_article(
161
+ llm_client=client,
162
+ article=article,
163
+ config={},
164
+ max_tags=2,
165
+ )
166
+ )
167
+
168
+ cve_tags = [tag for tag in selected if tag["category"] == "VULNERABILITY"]
169
+ regular_tags = [tag for tag in selected if tag["category"] != "VULNERABILITY"]
170
+ self.assertEqual(
171
+ {"cve-2025-1234", "cve-2026-123456"},
172
+ {tag["name"] for tag in cve_tags},
173
+ )
174
+ self.assertEqual(2, len(regular_tags))
175
+ self.assertEqual(4, len(selected))
176
+
177
+ def test_direct_cve_extraction_survives_llm_failure_and_zero_limit(self):
178
+ selected = asyncio.run(
179
+ self.manager.generate_tags_for_article(
180
+ llm_client=FakeTaggingClient(error=RuntimeError("LLM unavailable")),
181
+ article={
182
+ "id": "article-2",
183
+ "title": "Advisory for CVE-2026-9876",
184
+ "content": "",
185
+ },
186
+ config={},
187
+ max_tags=0,
188
+ )
189
+ )
190
+
191
+ self.assertEqual(["cve-2026-9876"], [tag["name"] for tag in selected])
192
+ self.assertEqual("VULNERABILITY", selected[0]["category"])
193
+
194
+ def test_llm_uses_database_defined_category_for_new_tag(self):
195
+ store = MemoryTagStore(
196
+ categories=[
197
+ {"name": "GENERAL", "label": "Allmän", "description": "Fallback"},
198
+ {
199
+ "name": "RISK_THEME",
200
+ "label": "Risktema",
201
+ "description": "Strategic and emerging risks",
202
+ },
203
+ ]
204
+ )
205
+ manager = TagManager(store)
206
+ client = FakeTaggingClient(
207
+ [
208
+ {"tag": "quux", "type": "CATEGORY", "category": "risk_theme"},
209
+ {"tag": "ransomware", "type": "CATEGORY", "category": "NOT_REAL"},
210
+ ]
211
+ )
212
+
213
+ selected = asyncio.run(
214
+ manager.generate_tags_for_article(
215
+ llm_client=client,
216
+ article={"id": "article-3", "title": "Quux ransomware risk"},
217
+ config={},
218
+ max_tags=2,
219
+ )
220
+ )
221
+
222
+ self.assertEqual(
223
+ {"quux": "RISK_THEME", "ransomware": "GENERAL"},
224
+ {tag["name"]: tag["category"] for tag in selected},
225
+ )
226
+ prompt = client.messages[0]["content"]
227
+ self.assertIn("RISK_THEME: Risktema: Strategic and emerging risks", prompt)
228
+ self.assertIn('Set "category" to exactly one name', prompt)
229
+
230
+
231
+ class VulnerabilityCategoryInitializationTests(unittest.TestCase):
232
+ def test_sqlite_custom_category_is_used_for_new_tag(self):
233
+ with TemporaryDirectory() as directory:
234
+ store = SqliteStore(str(Path(directory) / "tags.sqlite"))
235
+ store.initialize_default_categories()
236
+ store.create_category(
237
+ "RISK_THEME",
238
+ "Risktema",
239
+ description="Strategic and emerging risks",
240
+ )
241
+
242
+ tag_id = TagManager(store).add_tag("quantum risk", category="risk_theme")
243
+
244
+ stored_tag = store.get_tag_by_name("quantum risk")
245
+ self.assertEqual(tag_id, stored_tag["id"])
246
+ self.assertEqual("RISK_THEME", stored_tag["category"])
247
+
248
+ def test_tinydb_initializes_vulnerability_category(self):
249
+ with TemporaryDirectory() as directory:
250
+ store = TinyDBStore(str(Path(directory) / "tags.json"))
251
+ tag_id = store.add_tag("cve-2024-12345", category="DOMAIN_ENTITY")
252
+ store.initialize_default_categories()
253
+
254
+ categories = {category["name"] for category in store.get_all_categories()}
255
+ self.assertIn("VULNERABILITY", categories)
256
+ stored_tag = store.get_tag_by_name("cve-2024-12345")
257
+ self.assertEqual("VULNERABILITY", stored_tag["category"])
258
+ self.assertEqual(tag_id, stored_tag["id"])
259
+
260
+ def test_sqlite_initializes_vulnerability_category(self):
261
+ with TemporaryDirectory() as directory:
262
+ store = SqliteStore(str(Path(directory) / "tags.sqlite"))
263
+ tag_id = store.add_tag("CVE-2024-123456", category="DOMAIN_ENTITY")
264
+ store.initialize_default_categories()
265
+
266
+ categories = {category["name"] for category in store.get_all_categories()}
267
+ self.assertIn("VULNERABILITY", categories)
268
+ stored_tag = store.get_tag_by_name("CVE-2024-123456")
269
+ self.assertEqual("VULNERABILITY", stored_tag["category"])
270
+ self.assertEqual(tag_id, stored_tag["id"])
271
+
272
+
273
+ if __name__ == "__main__":
274
+ unittest.main()
@@ -1,131 +0,0 @@
1
- import unittest
2
- from pathlib import Path
3
- from tempfile import TemporaryDirectory
4
-
5
- from feedsummary_core.persistence.SqliteStore import SqliteStore
6
- from feedsummary_core.persistence.TinyDbStore import TinyDBStore
7
- from feedsummary_core.summarizer.tagging import TagManager
8
-
9
-
10
- class MemoryTagStore:
11
- def __init__(self):
12
- self.tags = []
13
-
14
- def add_tag(self, name, category="GENERAL", description=None):
15
- existing = self.get_tag_by_name(name)
16
- if existing:
17
- return existing["id"]
18
- tag = {
19
- "id": len(self.tags) + 1,
20
- "name": name,
21
- "category": category,
22
- "description": description,
23
- }
24
- self.tags.append(tag)
25
- return tag["id"]
26
-
27
- def get_tag_by_name(self, name):
28
- normalized = name.strip().lower()
29
- return next(
30
- (tag.copy() for tag in self.tags if tag["name"].lower() == normalized),
31
- None,
32
- )
33
-
34
- def get_all_tags(self):
35
- return [tag.copy() for tag in self.tags]
36
-
37
- def update_tag(self, tag_id, category=None, **_kwargs):
38
- tag = next(tag for tag in self.tags if tag["id"] == tag_id)
39
- if category is not None:
40
- tag["category"] = category
41
- return tag.copy()
42
-
43
-
44
- class CveCategoryTests(unittest.TestCase):
45
- def setUp(self):
46
- self.store = MemoryTagStore()
47
- self.manager = TagManager(self.store)
48
-
49
- def test_add_tag_assigns_vulnerability_to_uppercase_cve(self):
50
- tag_id = self.manager.add_tag("CVE-2026-12345", category="DOMAIN_ENTITY")
51
-
52
- tag = self.store.get_tag_by_name("CVE-2026-12345")
53
- self.assertEqual(tag_id, tag["id"])
54
- self.assertEqual("VULNERABILITY", tag["category"])
55
-
56
- def test_select_tag_assigns_vulnerability_to_lowercase_six_digit_cve(self):
57
- selected = self.manager.select_tags_for_article(
58
- "article-1",
59
- [{"name": "cve-2026-123456", "type": "NAMED_ENTITY"}],
60
- )
61
-
62
- self.assertEqual(1, len(selected))
63
- self.assertEqual("cve-2026-123456", selected[0]["name"])
64
- self.assertEqual("VULNERABILITY", selected[0]["category"])
65
-
66
- def test_existing_cve_is_moved_to_vulnerability(self):
67
- tag_id = self.store.add_tag("cve-2025-1234", category="DOMAIN_ENTITY")
68
-
69
- selected = self.manager.select_tags_for_article(
70
- "article-1",
71
- ["CVE-2025-1234"],
72
- )
73
-
74
- self.assertEqual("VULNERABILITY", selected[0]["category"])
75
- self.assertEqual(
76
- "VULNERABILITY",
77
- self.store.get_tag_by_name("cve-2025-1234")["category"],
78
- )
79
- self.assertEqual(tag_id, selected[0]["id"])
80
-
81
- def test_distinct_cves_are_not_fuzzy_matched_to_each_other(self):
82
- first = self.manager.select_tags_for_article(
83
- "article-1",
84
- ["CVE-2026-12345"],
85
- )
86
- second = self.manager.select_tags_for_article(
87
- "article-2",
88
- ["CVE-2026-12346"],
89
- )
90
-
91
- self.assertNotEqual(first[0]["id"], second[0]["id"])
92
- self.assertEqual(2, len(self.store.tags))
93
- self.assertTrue(
94
- all(tag["category"] == "VULNERABILITY" for tag in self.store.tags)
95
- )
96
-
97
- def test_non_cve_tag_keeps_requested_category(self):
98
- self.manager.add_tag("cve-2026-123", category="DOMAIN_ENTITY")
99
-
100
- tag = self.store.get_tag_by_name("cve-2026-123")
101
- self.assertEqual("DOMAIN_ENTITY", tag["category"])
102
-
103
-
104
- class VulnerabilityCategoryInitializationTests(unittest.TestCase):
105
- def test_tinydb_initializes_vulnerability_category(self):
106
- with TemporaryDirectory() as directory:
107
- store = TinyDBStore(str(Path(directory) / "tags.json"))
108
- tag_id = store.add_tag("cve-2024-12345", category="DOMAIN_ENTITY")
109
- store.initialize_default_categories()
110
-
111
- categories = {category["name"] for category in store.get_all_categories()}
112
- self.assertIn("VULNERABILITY", categories)
113
- stored_tag = store.get_tag_by_name("cve-2024-12345")
114
- self.assertEqual("VULNERABILITY", stored_tag["category"])
115
- self.assertEqual(tag_id, stored_tag["id"])
116
-
117
- def test_sqlite_initializes_vulnerability_category(self):
118
- with TemporaryDirectory() as directory:
119
- store = SqliteStore(str(Path(directory) / "tags.sqlite"))
120
- tag_id = store.add_tag("CVE-2024-123456", category="DOMAIN_ENTITY")
121
- store.initialize_default_categories()
122
-
123
- categories = {category["name"] for category in store.get_all_categories()}
124
- self.assertIn("VULNERABILITY", categories)
125
- stored_tag = store.get_tag_by_name("CVE-2024-123456")
126
- self.assertEqual("VULNERABILITY", stored_tag["category"])
127
- self.assertEqual(tag_id, stored_tag["id"])
128
-
129
-
130
- if __name__ == "__main__":
131
- unittest.main()