plm-knowledge 1.0.0__py3-none-any.whl
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.
- plm_knowledge/__init__.py +92 -0
- plm_knowledge/business_validator.py +279 -0
- plm_knowledge/contracts.py +178 -0
- plm_knowledge/foundation_loader.py +113 -0
- plm_knowledge/issue_classification.py +155 -0
- plm_knowledge/knowledge_pack_loader.py +177 -0
- plm_knowledge/knowledge_search_engine.py +140 -0
- plm_knowledge/lifecycle.py +115 -0
- plm_knowledge/manifests/foundation.dictionnaries.yaml +17 -0
- plm_knowledge/manifests/foundation.evaluation_grids.yaml +16 -0
- plm_knowledge/manifests/foundation.taxonomy.yaml +15 -0
- plm_knowledge/manifests/operational.placeholder.yaml +18 -0
- plm_knowledge/manifests/retrieval.bm25.yaml +20 -0
- plm_knowledge/manifests/retrieval.rag_service.yaml +21 -0
- plm_knowledge/manifests/rule.business_validator.yaml +20 -0
- plm_knowledge/manifests/rule.issue_classification.yaml +19 -0
- plm_knowledge/manifests.py +108 -0
- plm_knowledge/operational.py +96 -0
- plm_knowledge/rag_service.py +371 -0
- plm_knowledge/registry.py +112 -0
- plm_knowledge-1.0.0.dist-info/METADATA +93 -0
- plm_knowledge-1.0.0.dist-info/RECORD +24 -0
- plm_knowledge-1.0.0.dist-info/WHEEL +5 -0
- plm_knowledge-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Issue Classification Dictionary and Severity Policy (US-42.3 / FTR-42).
|
|
3
|
+
|
|
4
|
+
Governance asset — pure Python data structures (NOT persisted in DB).
|
|
5
|
+
Maps finding_type → issue classification metadata for deterministic,
|
|
6
|
+
traceable severity and resolution-category assignment.
|
|
7
|
+
"""
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Dict, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class IssueClassificationEntry:
|
|
14
|
+
"""Immutable classification record for a known issue type."""
|
|
15
|
+
issue_code: str # e.g. "STR_ORPHAN_NODE"
|
|
16
|
+
issue_label: str # e.g. "Orphan node"
|
|
17
|
+
issue_category: str # structural | data_quality | configuration
|
|
18
|
+
default_severity: str # critical | high | medium | low
|
|
19
|
+
default_priority: int # 0 = highest
|
|
20
|
+
default_resolution_category: str # auto_fix | suggested_remediation | business_decision_required
|
|
21
|
+
requires_business_review: bool
|
|
22
|
+
structural_scope_type: str # local_node | subtree | multi_node | structure_wide
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Severity Policy — explicit operational meaning for each level
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
SEVERITY_POLICY: Dict[str, str] = {
|
|
29
|
+
"critical": "Issue likely to prevent transformation, load, or core structure consistency",
|
|
30
|
+
"high": "Issue with high structural or migration impact requiring correction before safe continuation",
|
|
31
|
+
"medium": "Localized issue with limited migration impact, still requiring correction or review",
|
|
32
|
+
"low": "Signal worth surfacing but with low operational urgency",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
SEVERITY_ORDER: Dict[str, int] = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Issue Classification Dictionary — one entry per known finding_type
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
ISSUE_CLASSIFICATION_DICT: Dict[str, IssueClassificationEntry] = {
|
|
42
|
+
"cycle": IssueClassificationEntry(
|
|
43
|
+
issue_code="STR_CYCLE",
|
|
44
|
+
issue_label="Cycle detected",
|
|
45
|
+
issue_category="structural",
|
|
46
|
+
default_severity="critical",
|
|
47
|
+
default_priority=0,
|
|
48
|
+
default_resolution_category="business_decision_required",
|
|
49
|
+
requires_business_review=True,
|
|
50
|
+
structural_scope_type="subtree",
|
|
51
|
+
),
|
|
52
|
+
"missing_reference": IssueClassificationEntry(
|
|
53
|
+
issue_code="STR_MISSING_REF",
|
|
54
|
+
issue_label="Missing reference",
|
|
55
|
+
issue_category="structural",
|
|
56
|
+
default_severity="high",
|
|
57
|
+
default_priority=1,
|
|
58
|
+
default_resolution_category="suggested_remediation",
|
|
59
|
+
requires_business_review=False,
|
|
60
|
+
structural_scope_type="multi_node",
|
|
61
|
+
),
|
|
62
|
+
"self_reference": IssueClassificationEntry(
|
|
63
|
+
issue_code="STR_SELF_REF",
|
|
64
|
+
issue_label="Self-referencing relation",
|
|
65
|
+
issue_category="structural",
|
|
66
|
+
default_severity="high",
|
|
67
|
+
default_priority=1,
|
|
68
|
+
default_resolution_category="auto_fix",
|
|
69
|
+
requires_business_review=False,
|
|
70
|
+
structural_scope_type="local_node",
|
|
71
|
+
),
|
|
72
|
+
"broken_link": IssueClassificationEntry(
|
|
73
|
+
issue_code="STR_BROKEN_LINK",
|
|
74
|
+
issue_label="Broken link",
|
|
75
|
+
issue_category="structural",
|
|
76
|
+
default_severity="high",
|
|
77
|
+
default_priority=1,
|
|
78
|
+
default_resolution_category="suggested_remediation",
|
|
79
|
+
requires_business_review=False,
|
|
80
|
+
structural_scope_type="local_node",
|
|
81
|
+
),
|
|
82
|
+
"orphan_node": IssueClassificationEntry(
|
|
83
|
+
issue_code="STR_ORPHAN_NODE",
|
|
84
|
+
issue_label="Orphan node",
|
|
85
|
+
issue_category="structural",
|
|
86
|
+
default_severity="medium",
|
|
87
|
+
default_priority=2,
|
|
88
|
+
default_resolution_category="suggested_remediation",
|
|
89
|
+
requires_business_review=False,
|
|
90
|
+
structural_scope_type="local_node",
|
|
91
|
+
),
|
|
92
|
+
"duplicate_relation": IssueClassificationEntry(
|
|
93
|
+
issue_code="STR_DUPLICATE_REL",
|
|
94
|
+
issue_label="Duplicate relation",
|
|
95
|
+
issue_category="structural",
|
|
96
|
+
default_severity="medium",
|
|
97
|
+
default_priority=2,
|
|
98
|
+
default_resolution_category="auto_fix",
|
|
99
|
+
requires_business_review=False,
|
|
100
|
+
structural_scope_type="local_node",
|
|
101
|
+
),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Fallback — used when finding_type is unknown or unmapped
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
FALLBACK_ENTRY = IssueClassificationEntry(
|
|
109
|
+
issue_code="UNK_UNCLASSIFIED",
|
|
110
|
+
issue_label="Unclassified issue",
|
|
111
|
+
issue_category="unclassified",
|
|
112
|
+
default_severity="medium",
|
|
113
|
+
default_priority=3,
|
|
114
|
+
default_resolution_category="suggested_remediation",
|
|
115
|
+
requires_business_review=False,
|
|
116
|
+
structural_scope_type="local_node",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Helpers
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def classify_finding(finding_type: str) -> IssueClassificationEntry:
|
|
125
|
+
"""Look up classification for a finding_type. Falls back gracefully."""
|
|
126
|
+
return ISSUE_CLASSIFICATION_DICT.get(finding_type, FALLBACK_ENTRY)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def arbitrate_severity(finding_severity: str, dict_severity: str) -> str:
|
|
130
|
+
"""Severity arbitration: highest severity wins.
|
|
131
|
+
|
|
132
|
+
Returns the more severe of the two levels.
|
|
133
|
+
"""
|
|
134
|
+
f_rank = SEVERITY_ORDER.get(finding_severity, 99)
|
|
135
|
+
d_rank = SEVERITY_ORDER.get(dict_severity, 99)
|
|
136
|
+
# Lower rank = more severe
|
|
137
|
+
if f_rank <= d_rank:
|
|
138
|
+
return finding_severity
|
|
139
|
+
return dict_severity
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def compute_priority(severity: str, scope_type: str) -> int:
|
|
143
|
+
"""Derive priority from severity + scope.
|
|
144
|
+
|
|
145
|
+
Lower number = higher priority.
|
|
146
|
+
Wider scope at the same severity bumps priority up.
|
|
147
|
+
"""
|
|
148
|
+
base = SEVERITY_ORDER.get(severity, 3)
|
|
149
|
+
scope_bonus = {
|
|
150
|
+
"structure_wide": -1,
|
|
151
|
+
"subtree": 0,
|
|
152
|
+
"multi_node": 0,
|
|
153
|
+
"local_node": 1,
|
|
154
|
+
}
|
|
155
|
+
return max(0, base + scope_bonus.get(scope_type, 0))
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
KnowledgePackLoader — charge et découpe les fichiers Markdown de 01_Dictionnaries
|
|
3
|
+
en chunks indexables pour le moteur BM25.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Domaines PLM — heuristique sur le nom de fichier (stem)
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
_DOMAIN_RULES: list[tuple[list[str], str]] = [
|
|
17
|
+
(["EBOM", "MBOM", "BoM", "CAD BOM", "Baseline"], "bill_of_materials"),
|
|
18
|
+
(["Change Management", "EIA-649", "CMII"], "change_management"),
|
|
19
|
+
(["DFMEA", "APQP", "CQCP", "CQCS", "IMR", "ISO 10005"], "quality"),
|
|
20
|
+
# bpmn/ handled separately via path
|
|
21
|
+
(["AML", "AVL", "ATO", "MTO", "MTS", "CTO", "ETO"], "supply_chain"),
|
|
22
|
+
(["ALM", "PDM"], "pdm"),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
_EXCLUDED_STEMS = {"BIM (Building Information Modeling)", "IaaS", "PaaS", "SaaS"}
|
|
26
|
+
_EXCLUDED_DIRS = {"rgpd", "data", "prompt_library"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _map_domain(path: Path) -> Optional[str]:
|
|
30
|
+
"""Heuristique domaine à partir du chemin relatif."""
|
|
31
|
+
parts = [p.lower() for p in path.parts]
|
|
32
|
+
if "bpmn" in parts:
|
|
33
|
+
return "process_management"
|
|
34
|
+
if "ontology" in parts:
|
|
35
|
+
return "general_plm"
|
|
36
|
+
if "customers" in parts:
|
|
37
|
+
return "general_plm"
|
|
38
|
+
stem = path.stem
|
|
39
|
+
for keywords, domain in _DOMAIN_RULES:
|
|
40
|
+
for kw in keywords:
|
|
41
|
+
if kw.lower() in stem.lower():
|
|
42
|
+
return domain
|
|
43
|
+
return "general_plm"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _slug(text: str) -> str:
|
|
47
|
+
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Dataclass chunk
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
@dataclass
|
|
54
|
+
class KnowledgeChunk:
|
|
55
|
+
id: str # "{filename}#{section_slug}"
|
|
56
|
+
content: str # texte de la section
|
|
57
|
+
source_file: str # chemin relatif dans 01_Dictionnaries
|
|
58
|
+
section_title: str # titre du ## heading (ou "intro" pour contenu avant premier ##)
|
|
59
|
+
domain: Optional[str] = field(default=None)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Loader
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
class KnowledgePackLoader:
|
|
66
|
+
"""Charge les fichiers Markdown et les découpe par sections (## headings)."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, dictionnaries_path: str = "../../01_Dictionnaries") -> None:
|
|
69
|
+
self.root = Path(dictionnaries_path).resolve()
|
|
70
|
+
self.chunks: list[KnowledgeChunk] = []
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
def load(self) -> list[KnowledgeChunk]:
|
|
74
|
+
"""Charge tous les fichiers éligibles et retourne la liste de chunks."""
|
|
75
|
+
self.chunks = []
|
|
76
|
+
for path in self._get_relevant_files():
|
|
77
|
+
try:
|
|
78
|
+
new_chunks = self._chunk_markdown(path)
|
|
79
|
+
self.chunks.extend(new_chunks)
|
|
80
|
+
except Exception as exc: # noqa: BLE001
|
|
81
|
+
# Fichier illisible — on ignore sans bloquer
|
|
82
|
+
pass
|
|
83
|
+
return self.chunks
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
def _get_relevant_files(self) -> list[Path]:
|
|
87
|
+
if not self.root.exists():
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
files: list[Path] = []
|
|
91
|
+
|
|
92
|
+
# Tier 1 : plm/, bpmn/, ontology/
|
|
93
|
+
for folder in ("plm", "bpmn", "ontology"):
|
|
94
|
+
folder_path = self.root / folder
|
|
95
|
+
if folder_path.is_dir():
|
|
96
|
+
for md in folder_path.glob("*.md"):
|
|
97
|
+
if self._is_included(md):
|
|
98
|
+
files.append(md)
|
|
99
|
+
|
|
100
|
+
# Tier 2 : customers/schneider-electrics/
|
|
101
|
+
se_root = self.root / "customers" / "schneider-electrics"
|
|
102
|
+
if se_root.is_dir():
|
|
103
|
+
# Fichier racine Schneider
|
|
104
|
+
for md in se_root.glob("*.md"):
|
|
105
|
+
if self._is_included(md):
|
|
106
|
+
files.append(md)
|
|
107
|
+
# Sous-dossier "3. Epics"
|
|
108
|
+
epics_dir = se_root / "3. Epics"
|
|
109
|
+
if epics_dir.is_dir():
|
|
110
|
+
for md in epics_dir.glob("*.md"):
|
|
111
|
+
if self._is_included(md):
|
|
112
|
+
files.append(md)
|
|
113
|
+
|
|
114
|
+
return files
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
def _is_included(self, path: Path) -> bool:
|
|
118
|
+
rel = path.relative_to(self.root)
|
|
119
|
+
parts = [p.lower() for p in rel.parts]
|
|
120
|
+
# Exclure répertoires interdits
|
|
121
|
+
for excl in _EXCLUDED_DIRS:
|
|
122
|
+
if excl in parts:
|
|
123
|
+
return False
|
|
124
|
+
# Exclure stems explicitement exclus (plm/BIM*.md, IaaS, PaaS, SaaS)
|
|
125
|
+
stem = path.stem
|
|
126
|
+
if stem in _EXCLUDED_STEMS:
|
|
127
|
+
return False
|
|
128
|
+
if stem.startswith("BIM"):
|
|
129
|
+
return False
|
|
130
|
+
# Exclure fichiers index/attachments peu utiles
|
|
131
|
+
if stem.lower() in {"attachments", "index"}:
|
|
132
|
+
return False
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
def _chunk_markdown(self, path: Path) -> list[KnowledgeChunk]:
|
|
137
|
+
"""Découpe un fichier .md en sections délimitées par des ## headings."""
|
|
138
|
+
rel = str(path.relative_to(self.root))
|
|
139
|
+
domain = _map_domain(path.relative_to(self.root))
|
|
140
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
141
|
+
|
|
142
|
+
chunks: list[KnowledgeChunk] = []
|
|
143
|
+
# Split sur les headings de niveau 2 (##)
|
|
144
|
+
sections = re.split(r"\n(?=## )", text)
|
|
145
|
+
|
|
146
|
+
for i, section in enumerate(sections):
|
|
147
|
+
lines = section.strip().splitlines()
|
|
148
|
+
if not lines:
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
# Titre de section
|
|
152
|
+
if lines[0].startswith("## "):
|
|
153
|
+
title = lines[0][3:].strip()
|
|
154
|
+
body = "\n".join(lines[1:]).strip()
|
|
155
|
+
elif i == 0:
|
|
156
|
+
# Contenu avant le premier ## heading (intro / titre #)
|
|
157
|
+
# Prendre le premier # heading comme titre si présent
|
|
158
|
+
h1 = next((ln[2:].strip() for ln in lines if ln.startswith("# ")), path.stem)
|
|
159
|
+
title = h1
|
|
160
|
+
body = section.strip()
|
|
161
|
+
else:
|
|
162
|
+
title = path.stem
|
|
163
|
+
body = section.strip()
|
|
164
|
+
|
|
165
|
+
if not body or len(body) < 30:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
chunk_id = f"{_slug(path.stem)}#{_slug(title)}"
|
|
169
|
+
chunks.append(KnowledgeChunk(
|
|
170
|
+
id=chunk_id,
|
|
171
|
+
content=body[:4000], # cap à 4000 chars par chunk
|
|
172
|
+
source_file=rel,
|
|
173
|
+
section_title=title,
|
|
174
|
+
domain=domain,
|
|
175
|
+
))
|
|
176
|
+
|
|
177
|
+
return chunks
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
KnowledgeSearchEngine — index BM25 sur les KnowledgeChunks PLM.
|
|
3
|
+
|
|
4
|
+
Trois alternatives de moteur de recherche ont été évaluées :
|
|
5
|
+
1. BM25 (rank_bm25) — CHOIX RETENU
|
|
6
|
+
Avantages : dépendance ~10KB, démarrage instantané, excellent sur vocabulaire
|
|
7
|
+
technique précis (PLM, normes, acronymes). Pas de modèle à charger.
|
|
8
|
+
Limite : ne comprend pas les synonymes (ex: "validité" ≠ "effectivité").
|
|
9
|
+
|
|
10
|
+
2. TF-IDF (scikit-learn)
|
|
11
|
+
Qualité similaire à BM25 sur texte structuré. ~50MB de dépendance (sklearn).
|
|
12
|
+
Démarrage ~1s. Envisageable si sklearn est déjà présent dans le projet.
|
|
13
|
+
|
|
14
|
+
3. Embeddings sémantiques (sentence-transformers)
|
|
15
|
+
Comprend les synonymes et paraphrases. Modèle ~500MB à télécharger.
|
|
16
|
+
Démarrage 10-30s, RAM ~600MB. Recommandé si le corpus dépasse 1000 chunks
|
|
17
|
+
ou si les utilisateurs posent des questions en langage naturel non-technique.
|
|
18
|
+
Modèle suggéré : 'paraphrase-multilingual-MiniLM-L12-v2' (multilingue FR/EN).
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
from plm_knowledge.knowledge_pack_loader import KnowledgeChunk, KnowledgePackLoader
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from rank_bm25 import BM25Okapi
|
|
29
|
+
_BM25_AVAILABLE = True
|
|
30
|
+
except ImportError:
|
|
31
|
+
_BM25_AVAILABLE = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Tokenizer simple (alphanumérique + accents FR)
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
def _tokenize(text: str) -> list[str]:
|
|
38
|
+
return re.findall(r"[a-zA-Z0-9\u00C0-\u024F]+", text.lower())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Engine
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
class KnowledgeSearchEngine:
|
|
45
|
+
"""
|
|
46
|
+
Index BM25 chargé en mémoire. Recherche par query + filtre domaine optionnel.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
MIN_SCORE = 0.1 # Seuil de pertinence
|
|
50
|
+
|
|
51
|
+
def __init__(self, loader: KnowledgePackLoader) -> None:
|
|
52
|
+
self.chunks: list[KnowledgeChunk] = loader.chunks
|
|
53
|
+
self.is_ready = False
|
|
54
|
+
self._bm25: Optional[object] = None
|
|
55
|
+
self._tokenized: list[list[str]] = []
|
|
56
|
+
|
|
57
|
+
if not _BM25_AVAILABLE:
|
|
58
|
+
return
|
|
59
|
+
if not self.chunks:
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
self._tokenized = [_tokenize(c.content + " " + c.section_title) for c in self.chunks]
|
|
63
|
+
self._bm25 = BM25Okapi(self._tokenized)
|
|
64
|
+
self.is_ready = True
|
|
65
|
+
|
|
66
|
+
# ------------------------------------------------------------------
|
|
67
|
+
def search(
|
|
68
|
+
self,
|
|
69
|
+
query: str,
|
|
70
|
+
domain: Optional[str] = None,
|
|
71
|
+
top_k: int = 3,
|
|
72
|
+
) -> list[KnowledgeChunk]:
|
|
73
|
+
"""
|
|
74
|
+
Recherche les chunks les plus pertinents pour une query.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
query: Texte de la requête (ex: "effectivité eBOM révision")
|
|
78
|
+
domain: Filtre domaine optionnel (ex: "bill_of_materials")
|
|
79
|
+
top_k: Nombre maximal de résultats
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Liste triée par score BM25 décroissant, filtrée par MIN_SCORE.
|
|
83
|
+
"""
|
|
84
|
+
if not self.is_ready or not query:
|
|
85
|
+
return []
|
|
86
|
+
|
|
87
|
+
tokens = _tokenize(query)
|
|
88
|
+
if not tokens:
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
scores = self._bm25.get_scores(tokens)
|
|
92
|
+
|
|
93
|
+
# Associer score + chunk
|
|
94
|
+
scored = list(zip(scores, self.chunks))
|
|
95
|
+
|
|
96
|
+
# Filtre domaine
|
|
97
|
+
if domain:
|
|
98
|
+
scored = [(s, c) for s, c in scored if c.domain == domain or c.domain is None]
|
|
99
|
+
|
|
100
|
+
# Seuil de pertinence
|
|
101
|
+
scored = [(s, c) for s, c in scored if s >= self.MIN_SCORE]
|
|
102
|
+
|
|
103
|
+
# Tri décroissant + top_k
|
|
104
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
105
|
+
return [c for _, c in scored[:top_k]]
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
def format_for_prompt(self, chunks: list[KnowledgeChunk]) -> str:
|
|
109
|
+
"""
|
|
110
|
+
Formate les chunks en bloc texte prêt à être injecté dans un system prompt.
|
|
111
|
+
|
|
112
|
+
Format :
|
|
113
|
+
[Connaissances PLM pertinentes]
|
|
114
|
+
Source: plm/EBOM.md — ## Effectivité
|
|
115
|
+
<contenu...>
|
|
116
|
+
"""
|
|
117
|
+
if not chunks:
|
|
118
|
+
return ""
|
|
119
|
+
|
|
120
|
+
lines = ["[Connaissances PLM pertinentes]"]
|
|
121
|
+
for chunk in chunks:
|
|
122
|
+
lines.append(f"\nSource: {chunk.source_file} — ## {chunk.section_title}")
|
|
123
|
+
lines.append(chunk.content)
|
|
124
|
+
|
|
125
|
+
return "\n".join(lines)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# Singleton (lazy-loaded)
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
_engine_instance: Optional[KnowledgeSearchEngine] = None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def set_engine(engine: KnowledgeSearchEngine) -> None:
|
|
135
|
+
global _engine_instance
|
|
136
|
+
_engine_instance = engine
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_knowledge_engine() -> Optional[KnowledgeSearchEngine]:
|
|
140
|
+
return _engine_instance
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""4-state lifecycle FSM for knowledge-pack manifests.
|
|
2
|
+
|
|
3
|
+
Aligned verbatim with PRD §8.11.3 capability lifecycle, mirroring the
|
|
4
|
+
Wave 6.5 Conv A `plm_skill_packages.lifecycle` precedent (Decision #167
|
|
5
|
+
/ Q-W6.5A-2 = (a) "Align verbatim with PRD §8.11.3 4-state capability
|
|
6
|
+
lifecycle"). No new states, no knowledge-specific extensions, no
|
|
7
|
+
standalone divergence.
|
|
8
|
+
|
|
9
|
+
State chart::
|
|
10
|
+
|
|
11
|
+
draft ──► released ──► deprecated ──► retired
|
|
12
|
+
│
|
|
13
|
+
└──► (terminal)
|
|
14
|
+
|
|
15
|
+
States are **strictly forward-flowing**. The FSM rejects:
|
|
16
|
+
* any transition not in ``LIFECYCLE_TRANSITIONS``
|
|
17
|
+
* any move out of a terminal state
|
|
18
|
+
* skip transitions (e.g. ``draft → deprecated``)
|
|
19
|
+
|
|
20
|
+
Used by ``plm_knowledge.registry.KnowledgePackRegistry`` to gate
|
|
21
|
+
manifest publishing + by future admin tools (V1.1+) to mediate
|
|
22
|
+
state-change audit events.
|
|
23
|
+
|
|
24
|
+
Wave 6.5 Conv B constraint: this module is the SOLE lifecycle FSM
|
|
25
|
+
for knowledge-pack manifests. The shared 4-state contract sits in
|
|
26
|
+
``plm_shared.capability_registry`` (PRD §8.11.3) and is mirrored
|
|
27
|
+
here verbatim — string identifiers chosen to match for cross-checks
|
|
28
|
+
in ``tests/test_lifecycle.py``.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from typing import Tuple
|
|
33
|
+
|
|
34
|
+
# ── Canonical state identifiers (PRD §8.11.3 verbatim) ─────────────────────
|
|
35
|
+
|
|
36
|
+
STATE_DRAFT: str = "draft"
|
|
37
|
+
STATE_RELEASED: str = "released"
|
|
38
|
+
STATE_DEPRECATED: str = "deprecated"
|
|
39
|
+
STATE_RETIRED: str = "retired"
|
|
40
|
+
|
|
41
|
+
LIFECYCLE_STATES: Tuple[str, ...] = (
|
|
42
|
+
STATE_DRAFT,
|
|
43
|
+
STATE_RELEASED,
|
|
44
|
+
STATE_DEPRECATED,
|
|
45
|
+
STATE_RETIRED,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# ── Allowed transitions (forward-flowing only) ─────────────────────────────
|
|
49
|
+
#
|
|
50
|
+
# Each tuple is (from, to). Skips are rejected (no draft→deprecated). Out-
|
|
51
|
+
# of-terminal moves are rejected (no retired→anywhere).
|
|
52
|
+
|
|
53
|
+
LIFECYCLE_TRANSITIONS: Tuple[Tuple[str, str], ...] = (
|
|
54
|
+
(STATE_DRAFT, STATE_RELEASED),
|
|
55
|
+
(STATE_RELEASED, STATE_DEPRECATED),
|
|
56
|
+
(STATE_DEPRECATED, STATE_RETIRED),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
TERMINAL_STATES: Tuple[str, ...] = (STATE_RETIRED,)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class InvalidLifecycleTransition(ValueError):
|
|
63
|
+
"""Raised on any state move that is not in ``LIFECYCLE_TRANSITIONS``.
|
|
64
|
+
|
|
65
|
+
Carries the attempted (from_state, to_state) pair so callers
|
|
66
|
+
(e.g. registry publishing flow) can emit a precise audit event.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, from_state: str, to_state: str) -> None:
|
|
70
|
+
self.from_state = from_state
|
|
71
|
+
self.to_state = to_state
|
|
72
|
+
super().__init__(
|
|
73
|
+
f"invalid lifecycle transition: {from_state!r} → {to_state!r} "
|
|
74
|
+
f"(allowed: {sorted(LIFECYCLE_TRANSITIONS)})"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_terminal(state: str) -> bool:
|
|
79
|
+
"""Return True iff ``state`` is a terminal state (no outbound transitions)."""
|
|
80
|
+
return state in TERMINAL_STATES
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def can_transition(from_state: str, to_state: str) -> bool:
|
|
84
|
+
"""Return True iff ``(from_state, to_state)`` is in ``LIFECYCLE_TRANSITIONS``."""
|
|
85
|
+
return (from_state, to_state) in LIFECYCLE_TRANSITIONS
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def next_state(from_state: str, to_state: str) -> str:
|
|
89
|
+
"""Apply the transition and return ``to_state``; raise on invalid moves.
|
|
90
|
+
|
|
91
|
+
Pure function — does NOT persist; the caller (registry) writes the
|
|
92
|
+
new state into the manifest store after this returns successfully.
|
|
93
|
+
"""
|
|
94
|
+
if from_state not in LIFECYCLE_STATES:
|
|
95
|
+
raise InvalidLifecycleTransition(from_state, to_state)
|
|
96
|
+
if to_state not in LIFECYCLE_STATES:
|
|
97
|
+
raise InvalidLifecycleTransition(from_state, to_state)
|
|
98
|
+
if not can_transition(from_state, to_state):
|
|
99
|
+
raise InvalidLifecycleTransition(from_state, to_state)
|
|
100
|
+
return to_state
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
__all__ = [
|
|
104
|
+
"STATE_DRAFT",
|
|
105
|
+
"STATE_RELEASED",
|
|
106
|
+
"STATE_DEPRECATED",
|
|
107
|
+
"STATE_RETIRED",
|
|
108
|
+
"LIFECYCLE_STATES",
|
|
109
|
+
"LIFECYCLE_TRANSITIONS",
|
|
110
|
+
"TERMINAL_STATES",
|
|
111
|
+
"InvalidLifecycleTransition",
|
|
112
|
+
"is_terminal",
|
|
113
|
+
"can_transition",
|
|
114
|
+
"next_state",
|
|
115
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
pack_id: foundation.dictionnaries
|
|
2
|
+
kind: foundation_dictionary
|
|
3
|
+
title: Foundation — 01_Dictionnaries Markdown corpus
|
|
4
|
+
description: 146 PLM domain markdown files at 01_Dictionnaries/ (path resolved via plm_knowledge.foundation_loader; physical relocation deferred to Wave 7 per Decision #171).
|
|
5
|
+
version: 1.0.0
|
|
6
|
+
lifecycle_state: released
|
|
7
|
+
capability_id: knowledge.foundation.dictionnaries
|
|
8
|
+
entry_module: plm_knowledge.foundation_loader
|
|
9
|
+
entry_callable: list_foundation_documents
|
|
10
|
+
tags:
|
|
11
|
+
- foundation
|
|
12
|
+
- markdown
|
|
13
|
+
- dictionnaries
|
|
14
|
+
notes: |
|
|
15
|
+
Wave 6.5 Conv B carve: directory stays at repo root through Wave 6.5; the
|
|
16
|
+
thin loader resolves the path through backend `config.dictionnaries_path`.
|
|
17
|
+
Physical relocation lands at Wave 7 Extraction.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
pack_id: foundation.evaluation_grids
|
|
2
|
+
kind: foundation_evaluation_grid
|
|
3
|
+
title: Foundation — PLM evaluation grids
|
|
4
|
+
description: PLM evaluation rubrics consumed by the business validator + quality gate; resolved through the Foundation root path resolver.
|
|
5
|
+
version: 1.0.0
|
|
6
|
+
lifecycle_state: released
|
|
7
|
+
capability_id: knowledge.foundation.evaluation_grids
|
|
8
|
+
entry_module: plm_knowledge.foundation_loader
|
|
9
|
+
entry_callable: list_foundation_documents
|
|
10
|
+
tags:
|
|
11
|
+
- foundation
|
|
12
|
+
- evaluation
|
|
13
|
+
- rubric
|
|
14
|
+
notes: |
|
|
15
|
+
V1 sources rubrics from the shared 01_Dictionnaries tree (filtered subset).
|
|
16
|
+
V1.1+ may split into a dedicated foundation/grids/ subdirectory.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
pack_id: foundation.taxonomy
|
|
2
|
+
kind: foundation_taxonomy
|
|
3
|
+
title: Foundation — PLM taxonomy
|
|
4
|
+
description: PLM domain taxonomy mappings (e.g. EBOM/MBOM/CAD-BOM, Change Management, Quality, Supply Chain) used by the BM25 loader to assign domain tags.
|
|
5
|
+
version: 1.0.0
|
|
6
|
+
lifecycle_state: released
|
|
7
|
+
capability_id: knowledge.foundation.taxonomy
|
|
8
|
+
entry_module: plm_knowledge.knowledge_pack_loader
|
|
9
|
+
entry_callable: KnowledgePackLoader
|
|
10
|
+
tags:
|
|
11
|
+
- foundation
|
|
12
|
+
- taxonomy
|
|
13
|
+
notes: |
|
|
14
|
+
V1 surface lives inside knowledge_pack_loader's _DOMAIN_RULES heuristic.
|
|
15
|
+
V1.1+ promotes this to a standalone declarative file under foundation/.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
pack_id: operational.placeholder
|
|
2
|
+
kind: operational
|
|
3
|
+
title: Operational — V1 placeholder
|
|
4
|
+
description: V1 operational layer placeholder per D-LOCKED-15 + Decision #172. Stable but inert public surface so downstream callers can wire against the contract today.
|
|
5
|
+
version: 1.0.0
|
|
6
|
+
lifecycle_state: draft
|
|
7
|
+
capability_id: knowledge.operational.placeholder
|
|
8
|
+
entry_module: plm_knowledge.operational
|
|
9
|
+
entry_callable: record_operational_event
|
|
10
|
+
tags:
|
|
11
|
+
- operational
|
|
12
|
+
- placeholder
|
|
13
|
+
notes: |
|
|
14
|
+
V1 (Wave 6.5 Conv B): no-op — events are logged at INFO and
|
|
15
|
+
discarded. Lifecycle state intentionally `draft` to flag that the
|
|
16
|
+
V1.1+ implementation is the genuine release path. Decision #172
|
|
17
|
+
preserves in-process state at V1; persistence + analytics surface
|
|
18
|
+
lands via a dedicated story.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
pack_id: retrieval.bm25
|
|
2
|
+
kind: retrieval_bm25
|
|
3
|
+
title: Retrieval — BM25 search engine
|
|
4
|
+
description: BM25 (rank_bm25) search engine over PLM KnowledgeChunks loaded from 01_Dictionnaries/. Module-level singleton accessed via set_engine + get_knowledge_engine.
|
|
5
|
+
version: 1.0.0
|
|
6
|
+
lifecycle_state: released
|
|
7
|
+
capability_id: knowledge.retrieval.bm25
|
|
8
|
+
autonomy_level: supervised
|
|
9
|
+
entry_module: plm_knowledge.knowledge_search_engine
|
|
10
|
+
entry_callable: get_knowledge_engine
|
|
11
|
+
tags:
|
|
12
|
+
- retrieval
|
|
13
|
+
- bm25
|
|
14
|
+
- search
|
|
15
|
+
notes: |
|
|
16
|
+
Lifted from 02_App/backend/skills/knowledge_search_engine.py +
|
|
17
|
+
02_App/backend/skills/knowledge_pack_loader.py via git mv at Wave 6.5
|
|
18
|
+
Conv B. Re-export shims preserve legacy `skills.knowledge_*` imports
|
|
19
|
+
including the module-level singleton (set_engine + get_knowledge_engine
|
|
20
|
+
point at the canonical singleton in plm_knowledge).
|