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.
- {feedsummary_core-2.2.0/src/feedsummary_core.egg-info → feedsummary_core-2.2.1}/PKG-INFO +1 -1
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/TAGGING_QUICKSTART.md +1 -1
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/TAGGING_SYSTEM.md +25 -27
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/_version.py +3 -3
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/tagging.py +193 -25
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/tagging_integration.py +3 -3
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/tagging_rules.py +19 -1
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1/src/feedsummary_core.egg-info}/PKG-INFO +1 -1
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core.egg-info/scm_version.json +2 -2
- feedsummary_core-2.2.1/tests/test_tagging_cve_category.py +274 -0
- feedsummary_core-2.2.0/tests/test_tagging_cve_category.py +0 -131
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/.github/workflows/release.yaml +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/.gitignore +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/LICENSE +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/README.md +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/TAGGING_INTEGRATION_GUIDE.md +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/TAG_BASED_SUMMARY.md +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/examples/tagging_examples.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/pyproject.toml +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/setup.cfg +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/__init__.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/llm_client/__init__.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/llm_client/fallback_client.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/llm_client/ollama_cloud.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/llm_client/ollama_local.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/persistence/CleanUpPolicy.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/persistence/MongoDBStore.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/persistence/SqliteStore.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/persistence/TinyDbStore.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/persistence/__init__.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/persistence/helpers.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/persistence/migrate_tinydb_to_mongodb.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/prompts/loader.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/__init__.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/batching.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/chat.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/helpers.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/ingest.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/main.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/prompt_lab.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/prompt_replay.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/summarizer.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/token_budget.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core.egg-info/SOURCES.txt +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core.egg-info/dependency_links.txt +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core.egg-info/entry_points.txt +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core.egg-info/requires.txt +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core.egg-info/scm_file_list.json +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core.egg-info/top_level.txt +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/tests/test_domain_extraction.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/tests/test_embedding_persistence.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/tests/test_embedding_routing.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/tests/test_mongodb_store.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/tests/test_similarity_batching.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/tests/test_tag_based_summary.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/tests/test_tag_matching.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/tests/test_tag_similarity_consistency.py +0 -0
- {feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/tests/test_tinydb_mongodb_migration.py +0 -0
|
@@ -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
|
|
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (2, 2,
|
|
21
|
+
__version__ = version = '2.2.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 2, 1)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g4ab927c63'
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/tagging.py
RENAMED
|
@@ -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:
|
|
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
|
-
#
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
self.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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=
|
|
1281
|
+
candidate_tags=cve_candidates + regular_candidates[:regular_limit * 2],
|
|
1164
1282
|
allow_new_tags=True,
|
|
1165
1283
|
)
|
|
1166
1284
|
|
|
1167
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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:
|
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/llm_client/__init__.py
RENAMED
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/llm_client/fallback_client.py
RENAMED
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/llm_client/ollama_cloud.py
RENAMED
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/llm_client/ollama_local.py
RENAMED
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/persistence/CleanUpPolicy.py
RENAMED
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/persistence/MongoDBStore.py
RENAMED
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/persistence/SqliteStore.py
RENAMED
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/persistence/TinyDbStore.py
RENAMED
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/persistence/__init__.py
RENAMED
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/persistence/helpers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/__init__.py
RENAMED
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/batching.py
RENAMED
|
File without changes
|
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/helpers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/prompt_lab.py
RENAMED
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/prompt_replay.py
RENAMED
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/summarizer.py
RENAMED
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core/summarizer/token_budget.py
RENAMED
|
File without changes
|
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core.egg-info/requires.txt
RENAMED
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core.egg-info/scm_file_list.json
RENAMED
|
File without changes
|
{feedsummary_core-2.2.0 → feedsummary_core-2.2.1}/src/feedsummary_core.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|