Skip to content

Commit d3154ed

Browse files
dev to release/v0.5.1 (Pipelex#146)
* Refactor/improve architecture (Pipelex#144) * Better singleton * Fixed major bug in identifying structure of implicit concept --------- Co-authored-by: Louis Choquel <louis@pipelex.com> * Fix/ut concept library (Pipelex#145) * add changelog v0.5.1 --------- Co-authored-by: Louis Choquel <louis@pipelex.com>
1 parent cc35fcc commit d3154ed

12 files changed

Lines changed: 273 additions & 59 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## [v0.5.1] - 2025-07-09
4+
5+
## Fixed
6+
7+
- Fixed the `ConceptFactory.make_from_blueprint` method: Concepts defined in single-line format no longer automatically refine `TextContent` when a structure class with the same name exists
8+
- `ConceptFactory.make_concept_from_definition` is now `ConceptFactory.make_concept_from_definition_str`
9+
10+
## Added
11+
12+
- Bumped `kajson` to `v0.3.0`: Introducing `MetaSingleton` for better singleton management
13+
- Unit tests for `ConceptLibrary.is_compatible_by_concept_code`
14+
315
## [v0.5.0] - 2025-07-01
416

517
### Highlight: Vibe Coding an AI workflow becomes a reality

pipelex/core/concept.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def extract_domain_and_concept_from_str(cls, concept_str: str) -> Tuple[str, str
106106
if "." in concept_str:
107107
domain_code, concept_code = concept_str.split(".")
108108
return domain_code, concept_code
109-
raise ConceptError(f"No extraction of domain and concept from concept code '{concept_str}'")
109+
raise ConceptError(f"Could not extract domain and concept from concept code '{concept_str}'")
110110

111111
@classmethod
112112
def extract_concept_name_from_str(cls, concept_str: str) -> str:

pipelex/core/concept_factory.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,26 +82,33 @@ def make_from_details_dict(
8282
return the_concept
8383

8484
@classmethod
85-
def make_concept_from_definition(
85+
def make_concept_from_definition_str(
8686
cls,
8787
domain_code: str,
88-
code: str,
88+
concept_str: str,
8989
definition: str,
9090
) -> Concept:
9191
structure_class_name: str
92-
if Concept.is_valid_structure_class(structure_class_name=code):
92+
refines: List[str]
93+
if Concept.concept_str_contains_domain(concept_str=concept_str):
94+
concept_name = Concept.extract_concept_name_from_str(concept_str=concept_str)
95+
else:
96+
concept_name = concept_str
97+
if Concept.is_valid_structure_class(structure_class_name=concept_name):
9398
# structure is set implicitly, by the concept's code
94-
structure_class_name = code
99+
structure_class_name = concept_name
100+
refines = []
95101
else:
96102
structure_class_name = TextContent.__name__
103+
refines = [NativeConcept.TEXT.code]
97104

98105
try:
99106
the_concept = Concept(
100-
code=ConceptCodeFactory.make_concept_code(domain_code, code),
107+
code=ConceptCodeFactory.make_concept_code(domain_code, concept_name),
101108
domain=domain_code,
102109
definition=definition,
103110
structure_class_name=structure_class_name,
104-
refines=[NativeConcept.TEXT.code],
111+
refines=refines,
105112
)
106113
return Concept.model_validate(the_concept)
107114
except ValidationError as e:

pipelex/core/concept_library.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,22 @@ def is_compatible(self, tested_concept: Concept, wanted_concept: Concept) -> boo
106106
@override
107107
def is_compatible_by_concept_code(self, tested_concept_code: str, wanted_concept_code: str) -> bool:
108108
if wanted_concept_code == NativeConcept.ANYTHING.code:
109+
log.debug(
110+
f"Concept '{tested_concept_code}' is compatible with '{wanted_concept_code}' "
111+
f"because '{wanted_concept_code}' is '{NativeConcept.ANYTHING.code}'"
112+
)
109113
return True
110114
tested_concept = self.get_required_concept(concept_code=tested_concept_code)
111115
wanted_concept = self.get_required_concept(concept_code=wanted_concept_code)
112116
if tested_concept.code == wanted_concept.code:
117+
log.debug(f"Concept '{tested_concept_code}' is compatible with '{wanted_concept_code}' because they have the same code")
113118
return True
114119
for inherited_concept_code in tested_concept.refines:
115120
if self.is_compatible_by_concept_code(inherited_concept_code, wanted_concept_code):
121+
log.debug(
122+
f"Concept '{tested_concept_code}' is compatible with '{wanted_concept_code}' "
123+
f"because '{tested_concept_code}' refines '{inherited_concept_code}' which is compatible with '{wanted_concept_code}'"
124+
)
116125
return True
117126
return False
118127

@@ -134,9 +143,9 @@ def get_required_concept(self, concept_code: str) -> Concept:
134143
if self.is_concept_implicit(concept_code=concept_code):
135144
# The implicit concept is obviously coming with a domain (the one it is used in)
136145
# TODO: replace this with a concept factory method make_implicit_concept
137-
return ConceptFactory.make_concept_from_definition(
146+
return ConceptFactory.make_concept_from_definition_str(
138147
domain_code="implicit",
139-
code=Concept.extract_domain_and_concept_from_str(concept_str=concept_code)[1],
148+
concept_str=Concept.extract_domain_and_concept_from_str(concept_str=concept_code)[1],
140149
definition=concept_code,
141150
)
142151
else:

pipelex/libraries/library_manager.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -256,24 +256,29 @@ def _load_library_components_from_recursive_dict(
256256
continue
257257

258258
def _load_concepts(self, domain_code: str, obj_dict: Dict[str, Any]):
259-
for concept_code, concept_obj in obj_dict.items():
259+
for concept_str, concept_obj in obj_dict.items():
260260
if isinstance(concept_obj, str):
261261
# we only have a definition
262-
concept_from_def = ConceptFactory.make_concept_from_definition(domain_code=domain_code, code=concept_code, definition=concept_obj)
262+
definition = concept_obj
263+
concept_from_def = ConceptFactory.make_concept_from_definition_str(
264+
domain_code=domain_code,
265+
concept_str=concept_str,
266+
definition=definition,
267+
)
263268
self.concept_library.add_new_concept(concept=concept_from_def)
264269
elif isinstance(concept_obj, dict):
265270
# blueprint dict definition
266271
concept_obj_dict: Dict[str, Any] = concept_obj
267272
try:
268273
concept_from_dict = ConceptFactory.make_from_details_dict(
269-
domain_code=domain_code, code=concept_code, details_dict=concept_obj_dict
274+
domain_code=domain_code, code=concept_str, details_dict=concept_obj_dict
270275
)
271276
except ValidationError as exc:
272277
error_msg = format_pydantic_validation_error(exc)
273-
raise ConceptLibraryError(f"Error loading concept '{concept_code}' because of: {error_msg}") from exc
278+
raise ConceptLibraryError(f"Error loading concept '{concept_str}' because of: {error_msg}") from exc
274279
self.concept_library.add_new_concept(concept=concept_from_dict)
275280
else:
276-
raise ConceptLibraryError(f"Unexpected type for concept_code '{concept_code}' in domain '{domain_code}': {type(concept_obj)}")
281+
raise ConceptLibraryError(f"Unexpected type for concept_code '{concept_str}' in domain '{domain_code}': {type(concept_obj)}")
277282

278283
def _load_pipes(self, domain_code: str, obj_dict: Dict[str, Any]):
279284
for pipe_code, pipe_obj in obj_dict.items():

pipelex/pipelex.py

Lines changed: 16 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from importlib.metadata import metadata
2-
from typing import Any, ClassVar, List, Optional, Type
2+
from typing import Any, List, Optional, Type, cast
33

44
from dotenv import load_dotenv
55
from kajson.class_registry import ClassRegistry
66
from kajson.class_registry_abstract import ClassRegistryAbstract
77
from kajson.kajson_manager import KajsonManager
8+
from kajson.singleton import MetaSingleton
89
from pydantic import ValidationError
910
from rich import print
1011
from typing_extensions import Self
@@ -55,39 +56,9 @@
5556
PACKAGE_VERSION = metadata(PACKAGE_NAME)["Version"]
5657

5758

58-
class Pipelex:
59-
_pipelex_instance: ClassVar[Optional[Self]] = None
60-
61-
def __new__(
62-
cls,
63-
pipelex_cls: Optional[Type[Self]] = None,
64-
pipelex_hub: Optional[PipelexHub] = None,
65-
config_cls: Optional[Type[ConfigRoot]] = None,
66-
ready_made_config: Optional[ConfigRoot] = None,
67-
class_registry: Optional[ClassRegistryAbstract] = None,
68-
template_provider: Optional[TemplateLibrary] = None,
69-
llm_model_provider: Optional[LLMModelLibrary] = None,
70-
inference_manager: Optional[InferenceManager] = None,
71-
pipeline_manager: Optional[PipelineManager] = None,
72-
pipeline_tracker: Optional[PipelineTracker] = None,
73-
activity_manager: Optional[ActivityManagerProtocol] = None,
74-
reporting_delegate: Optional[ReportingProtocol] = None,
75-
) -> Self:
76-
if cls._pipelex_instance is not None:
77-
raise RuntimeError(
78-
"Pipelex is a singleton, it is instantiated only once. Its instance is private. All you need is accesible through the hub."
79-
)
80-
if pipelex_cls is None:
81-
pipelex_cls = cls
82-
83-
if not issubclass(pipelex_cls, cls):
84-
raise TypeError(f"{pipelex_cls!r} is not a subclass of {cls.__name__}")
85-
86-
return super().__new__(pipelex_cls)
87-
59+
class Pipelex(metaclass=MetaSingleton):
8860
def __init__(
8961
self,
90-
pipelex_cls: Optional[Type[Self]] = None,
9162
pipelex_hub: Optional[PipelexHub] = None,
9263
config_cls: Optional[Type[ConfigRoot]] = None,
9364
ready_made_config: Optional[ConfigRoot] = None,
@@ -153,7 +124,11 @@ def __init__(
153124
self.pipelex_hub.set_domain_provider(domain_provider=domain_library)
154125
self.pipelex_hub.set_concept_provider(concept_provider=concept_library)
155126
self.pipelex_hub.set_pipe_provider(pipe_provider=pipe_library)
156-
self.library_manager = LibraryManager(domain_library=domain_library, concept_library=concept_library, pipe_library=pipe_library)
127+
self.library_manager = LibraryManager(
128+
domain_library=domain_library,
129+
concept_library=concept_library,
130+
pipe_library=pipe_library,
131+
)
157132
self.library_manager.setup()
158133
self.pipelex_hub.set_library_manager(library_manager=self.library_manager)
159134

@@ -178,7 +153,6 @@ def __init__(
178153
self.activity_manager = ActivityManagerNoOp()
179154
self.pipelex_hub.set_activity_manager(activity_manager=self.activity_manager)
180155

181-
Pipelex._pipelex_instance = self
182156
log.debug(f"{PACKAGE_NAME} version {PACKAGE_VERSION} init done")
183157

184158
def setup(
@@ -257,10 +231,12 @@ def teardown(self):
257231
self.class_registry.teardown()
258232
func_registry.teardown()
259233

260-
Pipelex._pipelex_instance = None
261234
project_name = get_config().project_name
262235
log.debug(f"{PACKAGE_NAME} version {PACKAGE_VERSION} teardown done for {get_config().project_name} (except config & logs)")
263236
self.pipelex_hub.reset_config()
237+
# Clear the singleton instance from metaclass
238+
if self.__class__ in MetaSingleton.instances:
239+
del MetaSingleton.instances[self.__class__]
264240
print(f"{PACKAGE_NAME} version {PACKAGE_VERSION} config reset done for {project_name}")
265241

266242
# TODO: add kwargs to make() so that subclasses can employ specific parameters
@@ -274,10 +250,12 @@ def make(cls, structure_classes: Optional[List[Type[Any]]] = None) -> Self:
274250

275251
@classmethod
276252
def get_optional_instance(cls) -> Optional[Self]:
277-
return cls._pipelex_instance
253+
instance = MetaSingleton.instances.get(cls)
254+
return cast(Optional[Self], instance)
278255

279256
@classmethod
280257
def get_instance(cls) -> Self:
281-
if cls._pipelex_instance is None:
258+
instance = MetaSingleton.instances.get(cls)
259+
if instance is None:
282260
raise RuntimeError("Pipelex is not initialized")
283-
return cls._pipelex_instance
261+
return cast(Self, instance)

pyproject.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pipelex"
3-
version = "0.5.0"
3+
version = "0.5.1"
44
description = "Pipelex is an open-source dev tool based on a simple declarative language that lets you define replicable, structured, composable LLM pipelines."
55
authors = [{ name = "Evotis S.A.S.", email = "evotis@pipelex.com" }]
66
maintainers = [{ name = "Pipelex staff", email = "oss@pipelex.com" }]
@@ -24,7 +24,7 @@ dependencies = [
2424
"instructor>=1.8.3",
2525
"jinja2>=3.1.4",
2626
"json2html>=1.3.0",
27-
"kajson==0.2.3",
27+
"kajson==0.3.0",
2828
"markdown>=3.6",
2929
"networkx>=3.4.2",
3030
"openai>=1.60.1",
@@ -284,3 +284,6 @@ packages = ["pipelex"]
284284

285285
[tool.hatch.build.targets.sdist.force-include]
286286
"pyproject.toml" = "pipelex/pyproject.toml"
287+
288+
[tool.hatch.metadata]
289+
allow-direct-references = true

tests/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import pipelex.pipelex
1010
from pipelex import log
1111
from pipelex.config import get_config
12+
from pipelex.core.concept_provider_abstract import ConceptProviderAbstract
13+
from pipelex.hub import get_concept_provider
1214
from tests.cases.registry import Fruit
1315

1416
pytest_plugins = [
@@ -66,3 +68,9 @@ def cherry() -> Fruit:
6668
def blueberry() -> Fruit:
6769
"""Blueberry fruit fixture."""
6870
return Fruit(name="blueberry", color="blue")
71+
72+
73+
@pytest.fixture(scope="module")
74+
def concept_provider() -> ConceptProviderAbstract:
75+
"""Concept provider fixture for testing concept compatibility."""
76+
return get_concept_provider()
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from typing import Dict, List, Optional
2+
3+
from pydantic import Field
4+
5+
from pipelex.core.stuff_content import StructuredContent
6+
7+
8+
class FundamentalsDoc(StructuredContent):
9+
project_overview: Optional[str] = Field(
10+
None,
11+
description="Mission, key features, architecture diagram, demo links",
12+
)
13+
core_concepts: Optional[Dict[str, str]] = Field(
14+
None,
15+
description=(
16+
"Names and definitions for project-specific terms, acronyms, data model names, background knowledge, business rules, domain entities"
17+
),
18+
)
19+
repository_map: Optional[str] = Field(
20+
None,
21+
description="Directory layout explanation and purpose of each folder",
22+
)
23+
24+
25+
class DocumentationConcept(StructuredContent):
26+
"""A specialized documentation concept that extends FundamentalsDoc."""
27+
28+
title: str = Field(..., description="Title of the documentation")
29+
sections: List[str] = Field(default_factory=list, description="List of section names")
30+
last_updated: Optional[str] = Field(None, description="Last update timestamp")
31+
32+
33+
class MultiMediaConcept(StructuredContent):
34+
"""A concept that combines text and images."""
35+
36+
text_content: str = Field(..., description="The text content")
37+
image_urls: List[str] = Field(default_factory=list, description="List of image URLs")
38+
caption: Optional[str] = Field(None, description="Optional caption for the content")
39+
40+
41+
class IndependentConcept(StructuredContent):
42+
"""An independent concept with its own structure."""
43+
44+
unique_field: str = Field(..., description="A unique field for this concept")
45+
metadata: Dict[str, str] = Field(default_factory=dict, description="Metadata dictionary")
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
domain = "concept_library_tests"
2+
3+
[concept]
4+
# Simple concept with no structure - should default to Text
5+
SimpleTextConcept = "A simple concept that should default to Text"
6+
7+
# Concept with explicit structure class
8+
FundamentalsDoc = "A comprehensive overview of the fundamental concepts and principles of software engineering."
9+
10+
# Concept that explicitly refines Text
11+
[concept.ExplicitTextConcept]
12+
Concept = "A concept that explicitly refines Text"
13+
refines = ["Text"]
14+
15+
# Concept that refines Image
16+
[concept.ImageBasedConcept]
17+
Concept = "A concept based on images"
18+
refines = ["Image"]
19+
20+
# Concept that refines FundamentalsDoc
21+
[concept.DocumentationConcept]
22+
Concept = "A specialized documentation concept"
23+
structure = "DocumentationConcept"
24+
refines = ["FundamentalsDoc"]
25+
26+
# Concept that refines both Text and Image (multiple inheritance)
27+
[concept.MultiMediaConcept]
28+
Concept = "A concept that combines text and images"
29+
structure = "MultiMediaConcept"
30+
refines = ["Text", "Image"]
31+
32+
# Concept with custom structure that doesn't refine anything
33+
[concept.IndependentConcept]
34+
Concept = "An independent concept with custom structure"
35+
structure = "IndependentConcept"
36+
37+
# Concept that refines a non-native concept
38+
[concept.SpecializedDoc]
39+
Concept = "A specialized document that builds on FundamentalsDoc"
40+
refines = ["FundamentalsDoc"]
41+
42+
# Chain of inheritance: Text -> ExplicitTextConcept -> DerivedTextConcept
43+
[concept.DerivedTextConcept]
44+
Concept = "A concept derived from ExplicitTextConcept"
45+
refines = ["ExplicitTextConcept"]
46+

0 commit comments

Comments
 (0)