first commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Scoring package
|
||||
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Mots-clés techniques Kubernetes pour le scoring sémantique.
|
||||
Organisés par thème / topic.
|
||||
"""
|
||||
from typing import Dict, List
|
||||
|
||||
TOPIC_KEYWORDS: Dict[str, List[str]] = {
|
||||
"incident": [
|
||||
"incident", "post-mortem", "postmortem", "panne", "outage",
|
||||
"crash", "debug", "root cause", "rca", "blameless",
|
||||
],
|
||||
"architecture": [
|
||||
"architecture", "diagram", "schema", "multi-cluster", "multi cluster",
|
||||
"federation", "service mesh", "sidecar", "istio", "linkerd",
|
||||
],
|
||||
"observabilité": [
|
||||
"prometheus", "grafana", "alertmanager", "loki", "tracing",
|
||||
"jaeger", "opentelemetry", "metrics", "slo", "sla", "sli",
|
||||
"observabilité", "monitoring", "dashboards",
|
||||
],
|
||||
"sécurité": [
|
||||
"rbac", "pod security", "opa", "gatekeeper", "falco",
|
||||
"network policy", "secret", "vault", "trivy", "kubescape",
|
||||
],
|
||||
"ci_cd": [
|
||||
"argocd", "argo cd", "flux", "fluxcd", "helm", "kustomize",
|
||||
"gitops", "pipeline", "ci/cd", "tekton", "jenkins",
|
||||
],
|
||||
"scaling": [
|
||||
"hpa", "vpa", "keda", "scalabilité", "scaling", "autoscaling",
|
||||
"horizontal", "vertical", "cluster autoscaler", "cost",
|
||||
],
|
||||
"migration": [
|
||||
"migration", "upgrade", "mise à jour", "version", "deprecation",
|
||||
"zero downtime", "rolling update", "canary", "blue green",
|
||||
],
|
||||
"storage": [
|
||||
"pvc", "persistent volume", "csi", "storage class", "statefulset",
|
||||
"rook", "ceph", "nfs", "longhorn", "backup", "velero",
|
||||
],
|
||||
}
|
||||
|
||||
# Liste plate pour détection rapide
|
||||
ALL_KEYWORDS: List[str] = [kw for kws in TOPIC_KEYWORDS.values() for kw in kws]
|
||||
|
||||
# Mots-clés avancés (bonus score élevé)
|
||||
ADVANCED_KEYWORDS: List[str] = [
|
||||
"post-mortem", "postmortem", "root cause", "blameless",
|
||||
"service mesh", "istio", "linkerd", "opentelemetry",
|
||||
"rbac", "opa", "gatekeeper", "falco",
|
||||
"argocd", "gitops", "keda", "hpa",
|
||||
"multi-cluster", "federation", "zero downtime",
|
||||
"slo", "sla", "sli", "cluster autoscaler",
|
||||
]
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Algorithme de scoring des vidéos YouTube Kubernetes.
|
||||
Score normalisé sur 100.
|
||||
|
||||
Critères :
|
||||
- Vues pondérées par ancienneté : 25 pts
|
||||
- Ratio likes / vues : 20 pts
|
||||
- Mots-clés techniques détectés : 25 pts
|
||||
- Durée >= 10 min : 10 pts
|
||||
- Présence de chapitrage : 10 pts
|
||||
- Nombre de topics détectés : 10 pts
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, List, Tuple
|
||||
|
||||
from .keywords import TOPIC_KEYWORDS, ADVANCED_KEYWORDS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from api.models import Video
|
||||
|
||||
|
||||
def _detect_topics(text: str) -> List[str]:
|
||||
"""Retourne les topics détectés dans un texte (titre + tags)."""
|
||||
text_lower = text.lower()
|
||||
topics = []
|
||||
for topic, keywords in TOPIC_KEYWORDS.items():
|
||||
if any(kw in text_lower for kw in keywords):
|
||||
topics.append(topic)
|
||||
return topics
|
||||
|
||||
|
||||
def _keyword_score(text: str) -> float:
|
||||
"""Score basé sur les mots-clés techniques (0-25)."""
|
||||
text_lower = text.lower()
|
||||
# Nombre de mots-clés avancés trouvés
|
||||
advanced_hits = sum(1 for kw in ADVANCED_KEYWORDS if kw in text_lower)
|
||||
# Nombre de mots-clés totaux
|
||||
from .keywords import ALL_KEYWORDS
|
||||
total_hits = sum(1 for kw in ALL_KEYWORDS if kw in text_lower)
|
||||
|
||||
# On plafonne à 5 hits avancés et 10 hits totaux
|
||||
score = min(advanced_hits / 5, 1.0) * 15 + min(total_hits / 10, 1.0) * 10
|
||||
return round(score, 2)
|
||||
|
||||
|
||||
def _view_score(view_count: int, age_days: float) -> float:
|
||||
"""Vues pondérées par ancienneté (0-25)."""
|
||||
if age_days <= 0:
|
||||
age_days = 1
|
||||
# Vues par jour, log-normalisé
|
||||
vpd = view_count / age_days
|
||||
# Référence : 1000 vues/jour = score max
|
||||
score = min(math.log1p(vpd) / math.log1p(1000), 1.0) * 25
|
||||
return round(score, 2)
|
||||
|
||||
|
||||
def _like_ratio_score(like_count: int, view_count: int) -> float:
|
||||
"""Ratio likes/vues (0-20). Référence : 5% = max."""
|
||||
if view_count == 0:
|
||||
return 0.0
|
||||
ratio = like_count / view_count
|
||||
score = min(ratio / 0.05, 1.0) * 20
|
||||
return round(score, 2)
|
||||
|
||||
|
||||
def _duration_score(duration_seconds: int) -> float:
|
||||
"""10 pts si durée >= 10 min, sinon 0."""
|
||||
return 10.0 if duration_seconds >= 600 else 0.0
|
||||
|
||||
|
||||
def _chapters_score(has_chapters: bool) -> float:
|
||||
"""10 pts si la vidéo a des chapitres."""
|
||||
return 10.0 if has_chapters else 0.0
|
||||
|
||||
|
||||
def _topics_score(topics: list[str]) -> float:
|
||||
"""10 pts selon le nb de topics distincts (max 3)."""
|
||||
return round(min(len(topics) / 3, 1.0) * 10, 2)
|
||||
|
||||
|
||||
def score_video(video_data: dict) -> Tuple[float, List[str]]:
|
||||
"""
|
||||
Calcule le score d'une vidéo et retourne (score, topics).
|
||||
video_data doit contenir les clés du modèle Video.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
published_at = video_data["published_at"]
|
||||
if isinstance(published_at, str):
|
||||
from datetime import datetime as dt
|
||||
published_at = dt.fromisoformat(published_at.replace("Z", "+00:00"))
|
||||
|
||||
age_days = (now - published_at).total_seconds() / 86400
|
||||
|
||||
# Texte analysable : titre + tags
|
||||
tags_text = " ".join(video_data.get("tags", []))
|
||||
full_text = f"{video_data['title']} {tags_text}"
|
||||
|
||||
topics = _detect_topics(full_text)
|
||||
|
||||
raw = (
|
||||
_view_score(video_data["view_count"], age_days)
|
||||
+ _like_ratio_score(video_data["like_count"], video_data["view_count"])
|
||||
+ _keyword_score(full_text)
|
||||
+ _duration_score(video_data["duration_seconds"])
|
||||
+ _chapters_score(video_data.get("has_chapters", False))
|
||||
+ _topics_score(topics)
|
||||
)
|
||||
|
||||
# Normaliser sur 100 (max théorique = 25+20+25+10+10+10 = 100)
|
||||
final_score = round(min(raw, 100.0), 1)
|
||||
return final_score, topics
|
||||
@@ -0,0 +1 @@
|
||||
# Tests scoring
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Tests du scorer Kubernetes."""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from scoring.scorer import score_video, _detect_topics, _keyword_score
|
||||
|
||||
|
||||
def _base_video(**kwargs) -> dict:
|
||||
defaults = {
|
||||
"id": "test123",
|
||||
"title": "Kubernetes en production",
|
||||
"channel": "DevOps France",
|
||||
"published_at": datetime.now(timezone.utc) - timedelta(days=5),
|
||||
"duration_seconds": 1800, # 30 min
|
||||
"view_count": 5000,
|
||||
"like_count": 250,
|
||||
"thumbnail_url": "https://example.com/thumb.jpg",
|
||||
"youtube_url": "https://youtube.com/watch?v=test123",
|
||||
"tags": ["kubernetes", "production", "devops"],
|
||||
"has_chapters": True,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return defaults
|
||||
|
||||
|
||||
class TestScoreVideo:
|
||||
def test_score_is_between_0_and_100(self):
|
||||
v = _base_video()
|
||||
score, topics = score_video(v)
|
||||
assert 0 <= score <= 100
|
||||
|
||||
def test_long_video_scores_higher_than_short(self):
|
||||
long_v = _base_video(duration_seconds=1800)
|
||||
short_v = _base_video(duration_seconds=300)
|
||||
score_long, _ = score_video(long_v)
|
||||
score_short, _ = score_video(short_v)
|
||||
assert score_long > score_short
|
||||
|
||||
def test_chapters_increase_score(self):
|
||||
with_ch = _base_video(has_chapters=True)
|
||||
without_ch = _base_video(has_chapters=False)
|
||||
s_with, _ = score_video(with_ch)
|
||||
s_without, _ = score_video(without_ch)
|
||||
assert s_with > s_without
|
||||
|
||||
def test_advanced_keywords_increase_score(self):
|
||||
basic = _base_video(title="Kubernetes débutant", tags=[])
|
||||
advanced = _base_video(
|
||||
title="Kubernetes post-mortem ArgoCD Prometheus",
|
||||
tags=["istio", "hpa", "keda"],
|
||||
)
|
||||
s_basic, _ = score_video(basic)
|
||||
s_advanced, _ = score_video(advanced)
|
||||
assert s_advanced > s_basic
|
||||
|
||||
def test_topics_detected(self):
|
||||
v = _base_video(title="Kubernetes ArgoCD CI/CD pipeline GitOps", tags=["argocd", "fluxcd"])
|
||||
_, topics = score_video(v)
|
||||
assert "ci_cd" in topics
|
||||
|
||||
def test_incident_topic_detected(self):
|
||||
v = _base_video(title="Kubernetes post-mortem incident production outage")
|
||||
_, topics = score_video(v)
|
||||
assert "incident" in topics
|
||||
|
||||
def test_zero_views_handled(self):
|
||||
v = _base_video(view_count=0, like_count=0)
|
||||
score, _ = score_video(v)
|
||||
assert score >= 0
|
||||
|
||||
|
||||
class TestDetectTopics:
|
||||
def test_returns_list(self):
|
||||
topics = _detect_topics("Kubernetes monitoring Prometheus")
|
||||
assert isinstance(topics, list)
|
||||
|
||||
def test_empty_text(self):
|
||||
topics = _detect_topics("")
|
||||
assert topics == []
|
||||
|
||||
|
||||
class TestKeywordScore:
|
||||
def test_no_keywords(self):
|
||||
s = _keyword_score("bonjour le monde")
|
||||
assert s == 0.0
|
||||
|
||||
def test_advanced_keyword(self):
|
||||
s = _keyword_score("post-mortem kubernetes production")
|
||||
assert s > 0
|
||||
Reference in New Issue
Block a user