From ae6e5a43d67042569923a98c75b1146bc7abb9ad Mon Sep 17 00:00:00 2001 From: BIN Zhang Date: Mon, 16 Mar 2026 00:51:21 +0800 Subject: [PATCH 1/2] feat: split builtin task type definitions --- demos/task_registry.py | 97 ++++++------------------------------------ demos/task_types.py | 77 +++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 85 deletions(-) create mode 100644 demos/task_types.py diff --git a/demos/task_registry.py b/demos/task_registry.py index 07caf74..1df88ea 100644 --- a/demos/task_registry.py +++ b/demos/task_registry.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib from dataclasses import dataclass from typing import Any, Callable @@ -159,88 +160,14 @@ def build_deliverable(task_type: str, context: Any) -> dict[str, Any]: return deliverable -COMMON_STAGE_SEQUENCE = ( - StageDefinition( - stage_name="design", - persona_id="design_persona", - progress_label="Designing", - deliverable_section="design_brief", - ), - StageDefinition( - stage_name="research", - persona_id="research_persona", - progress_label="Researching", - deliverable_section="research_summary", - depends_on=("design",), - ), - StageDefinition( - stage_name="marketing", - persona_id="marketing_persona", - progress_label="Marketing", - deliverable_section="marketing_plan", - depends_on=("design", "research"), - ), -) - - -register_persona( - PersonaDefinition( - persona_id="design_persona", - file_path="personas/design_persona.json", - ) -) -register_persona( - PersonaDefinition( - persona_id="research_persona", - file_path="personas/researcher_persona.json", - ) -) -register_persona( - PersonaDefinition( - persona_id="marketing_persona", - file_path="personas/marketing_persona.json", - ) -) - - -from demos import stage_handlers as _stage_handlers - - -register_task_type( - TaskTypeDefinition( - task_type="market_research", - deliverable_type="market_strategy_report", - stage_sequence=COMMON_STAGE_SEQUENCE, - stage_handlers={ - "design": "market_research.design", - "research": "market_research.research", - "marketing": "market_research.marketing", - }, - ) -) - -register_task_type( - TaskTypeDefinition( - task_type="product_design", - deliverable_type="product_concept_brief", - stage_sequence=COMMON_STAGE_SEQUENCE, - stage_handlers={ - "design": "product_design.design", - "research": "product_design.research", - "marketing": "product_design.marketing", - }, - ) -) - -register_task_type( - TaskTypeDefinition( - task_type="ux_review", - deliverable_type="ux_improvement_plan", - stage_sequence=COMMON_STAGE_SEQUENCE, - stage_handlers={ - "design": "ux_review.design", - "research": "ux_review.research", - "marketing": "ux_review.marketing", - }, - ) -) +def load_builtin_registry() -> None: + importlib.import_module("demos.stage_handlers") + from demos.task_types import BUILTIN_PERSONAS, BUILTIN_TASK_TYPES + + for persona in BUILTIN_PERSONAS: + register_persona(persona) + for task_type in BUILTIN_TASK_TYPES: + register_task_type(task_type) + + +load_builtin_registry() diff --git a/demos/task_types.py b/demos/task_types.py new file mode 100644 index 0000000..ea558fc --- /dev/null +++ b/demos/task_types.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from demos.task_registry import PersonaDefinition, StageDefinition, TaskTypeDefinition + + +COMMON_STAGE_SEQUENCE = ( + StageDefinition( + stage_name="design", + persona_id="design_persona", + progress_label="Designing", + deliverable_section="design_brief", + ), + StageDefinition( + stage_name="research", + persona_id="research_persona", + progress_label="Researching", + deliverable_section="research_summary", + depends_on=("design",), + ), + StageDefinition( + stage_name="marketing", + persona_id="marketing_persona", + progress_label="Marketing", + deliverable_section="marketing_plan", + depends_on=("design", "research"), + ), +) + + +BUILTIN_PERSONAS = ( + PersonaDefinition( + persona_id="design_persona", + file_path="personas/design_persona.json", + ), + PersonaDefinition( + persona_id="research_persona", + file_path="personas/researcher_persona.json", + ), + PersonaDefinition( + persona_id="marketing_persona", + file_path="personas/marketing_persona.json", + ), +) + + +BUILTIN_TASK_TYPES = ( + TaskTypeDefinition( + task_type="market_research", + deliverable_type="market_strategy_report", + stage_sequence=COMMON_STAGE_SEQUENCE, + stage_handlers={ + "design": "market_research.design", + "research": "market_research.research", + "marketing": "market_research.marketing", + }, + ), + TaskTypeDefinition( + task_type="product_design", + deliverable_type="product_concept_brief", + stage_sequence=COMMON_STAGE_SEQUENCE, + stage_handlers={ + "design": "product_design.design", + "research": "product_design.research", + "marketing": "product_design.marketing", + }, + ), + TaskTypeDefinition( + task_type="ux_review", + deliverable_type="ux_improvement_plan", + stage_sequence=COMMON_STAGE_SEQUENCE, + stage_handlers={ + "design": "ux_review.design", + "research": "ux_review.research", + "marketing": "ux_review.marketing", + }, + ), +) From 83fb1bfc6fa443d567fe8742ed2f21db4de86967 Mon Sep 17 00:00:00 2001 From: Bin Zhang <138868899+joy7758@users.noreply.github.com> Date: Mon, 16 Mar 2026 01:27:15 +0800 Subject: [PATCH 2/2] feat: split builtin persona definitions (#18) * feat: split builtin persona definitions * feat: add registry discovery entrypoint (#19) * feat: add registry discovery entrypoint * feat: support external plugin packages (#20) --- demos/discovery.py | 100 +++++++++++++++++++++++++++++++++ demos/persona_definitions.py | 22 ++++++++ demos/persona_workflow_demo.py | 10 +++- demos/stage_handlers.py | 41 +++++--------- demos/task_registry.py | 84 ++++++++++++++++++++++++--- demos/task_types.py | 23 ++------ 6 files changed, 225 insertions(+), 55 deletions(-) create mode 100644 demos/discovery.py create mode 100644 demos/persona_definitions.py diff --git a/demos/discovery.py b/demos/discovery.py new file mode 100644 index 0000000..40622e1 --- /dev/null +++ b/demos/discovery.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import importlib +import os +import pkgutil +import sys +from pathlib import Path +from types import ModuleType + +PLUGIN_PACKAGES_ENV = "POP_PLUGIN_PACKAGES" +PLUGIN_PACKAGE_PATHS_ENV = "POP_PLUGIN_PACKAGE_PATHS" + + +def split_package_names(value: str) -> tuple[str, ...]: + return tuple(part.strip() for part in value.split(",") if part.strip()) + + +def split_search_paths(value: str) -> tuple[Path, ...]: + return tuple( + Path(part).expanduser().resolve() + for part in value.split(os.pathsep) + if part.strip() + ) + + +def configure_plugin_search_paths(paths: tuple[Path, ...]) -> tuple[Path, ...]: + configured: list[Path] = [] + for path in paths: + if not path.exists(): + raise FileNotFoundError(f"Plugin package path does not exist: {path}") + path_str = str(path) + if path_str not in sys.path: + sys.path.append(path_str) + configured.append(path) + return tuple(configured) + + +def configured_package_names( + default_packages: tuple[str, ...] = ("demos",), + extra_packages: tuple[str, ...] = (), +) -> tuple[str, ...]: + plugin_paths = split_search_paths(os.environ.get(PLUGIN_PACKAGE_PATHS_ENV, "")) + configure_plugin_search_paths(plugin_paths) + env_packages = split_package_names(os.environ.get(PLUGIN_PACKAGES_ENV, "")) + + ordered_packages: list[str] = [] + seen: set[str] = set() + for package_name in (*default_packages, *extra_packages, *env_packages): + if package_name and package_name not in seen: + ordered_packages.append(package_name) + seen.add(package_name) + return tuple(ordered_packages) + + +def import_matching_modules( + package_names: str | tuple[str, ...], + exact_names: tuple[str, ...] = (), + prefixes: tuple[str, ...] = (), +) -> list[ModuleType]: + if isinstance(package_names, str): + package_names = (package_names,) + + imported_modules: list[ModuleType] = [] + seen_module_names: set[str] = set() + + for package_name in package_names: + package = importlib.import_module(package_name) + module_names: set[str] = set() + package_paths = getattr(package, "__path__", None) + + if package_paths is not None: + for _, name, _ in pkgutil.iter_modules(package_paths): + if name in exact_names or any(name.startswith(prefix) for prefix in prefixes): + module_names.add(name) + else: + for name in exact_names: + qualified_name = f"{package_name}.{name}" + if importlib.util.find_spec(qualified_name) is not None: + module_names.add(name) + + for module_name in sorted(module_names): + qualified_name = f"{package_name}.{module_name}" + if qualified_name in seen_module_names: + continue + imported_modules.append(importlib.import_module(qualified_name)) + seen_module_names.add(qualified_name) + + return imported_modules + + +def collect_module_exports( + package_names: str | tuple[str, ...], + export_name: str, + exact_names: tuple[str, ...] = (), + prefixes: tuple[str, ...] = (), +) -> tuple[object, ...]: + exports: list[object] = [] + for module in import_matching_modules(package_names, exact_names, prefixes): + exports.extend(getattr(module, export_name, ())) + return tuple(exports) diff --git a/demos/persona_definitions.py b/demos/persona_definitions.py new file mode 100644 index 0000000..e335c08 --- /dev/null +++ b/demos/persona_definitions.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from demos.task_registry import PersonaDefinition + + +PERSONA_DEFINITIONS = ( + PersonaDefinition( + persona_id="design_persona", + file_path="personas/design_persona.json", + ), + PersonaDefinition( + persona_id="research_persona", + file_path="personas/researcher_persona.json", + ), + PersonaDefinition( + persona_id="marketing_persona", + file_path="personas/marketing_persona.json", + ), +) + + +BUILTIN_PERSONAS = PERSONA_DEFINITIONS diff --git a/demos/persona_workflow_demo.py b/demos/persona_workflow_demo.py index 46b1b6d..267ed1d 100644 --- a/demos/persona_workflow_demo.py +++ b/demos/persona_workflow_demo.py @@ -16,7 +16,7 @@ from demos.task_registry import ( build_deliverable, build_stage_output, - persona_path_for, + resolve_persona_path, stage_handler_id_for, stage_sequence_for, supported_task_types, @@ -155,7 +155,7 @@ def load_personas_for_task(task_type: str) -> dict[str, Persona]: for stage in stage_sequence_for(task_type): if stage.persona_id in personas: continue - persona_path = PROJECT_ROOT / persona_path_for(stage.persona_id) + persona_path = resolve_persona_path(stage.persona_id, PROJECT_ROOT) personas[stage.persona_id] = load_persona(persona_path) return personas @@ -272,7 +272,11 @@ def workflow(task_input_path: Path, output_path: Path | None = None) -> Path: def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( - description="Run the persona workflow demo with dynamic task input." + description=( + "Run the persona workflow demo with dynamic task input. " + "External plugin packages can be added with " + "POP_PLUGIN_PACKAGES and POP_PLUGIN_PACKAGE_PATHS." + ) ) parser.add_argument( "--task-input", diff --git a/demos/stage_handlers.py b/demos/stage_handlers.py index 3a7fb63..166d908 100644 --- a/demos/stage_handlers.py +++ b/demos/stage_handlers.py @@ -2,7 +2,7 @@ from typing import Any -from demos.task_registry import StageHandlerDefinition, register_stage_handler +from demos.task_registry import StageHandlerDefinition def subject_inputs(task_input: dict[str, Any]) -> dict[str, Any]: @@ -257,57 +257,44 @@ def build_ux_review_marketing_output(task_input: dict[str, Any]) -> dict[str, An } -register_stage_handler( +STAGE_HANDLER_DEFINITIONS = ( StageHandlerDefinition( handler_id="market_research.design", builder=build_market_research_design_output, - ) -) -register_stage_handler( + ), StageHandlerDefinition( handler_id="market_research.research", builder=build_market_research_research_output, - ) -) -register_stage_handler( + ), StageHandlerDefinition( handler_id="market_research.marketing", builder=build_market_research_marketing_output, - ) -) -register_stage_handler( + ), StageHandlerDefinition( handler_id="product_design.design", builder=build_product_design_design_output, - ) -) -register_stage_handler( + ), StageHandlerDefinition( handler_id="product_design.research", builder=build_product_design_research_output, - ) -) -register_stage_handler( + ), StageHandlerDefinition( handler_id="product_design.marketing", builder=build_product_design_marketing_output, - ) -) -register_stage_handler( + ), StageHandlerDefinition( handler_id="ux_review.design", builder=build_ux_review_design_output, - ) -) -register_stage_handler( + ), StageHandlerDefinition( handler_id="ux_review.research", builder=build_ux_review_research_output, - ) -) -register_stage_handler( + ), StageHandlerDefinition( handler_id="ux_review.marketing", builder=build_ux_review_marketing_output, - ) + ), ) + + +BUILTIN_STAGE_HANDLERS = STAGE_HANDLER_DEFINITIONS diff --git a/demos/task_registry.py b/demos/task_registry.py index 1df88ea..2813570 100644 --- a/demos/task_registry.py +++ b/demos/task_registry.py @@ -2,6 +2,7 @@ import importlib from dataclasses import dataclass +from pathlib import Path from typing import Any, Callable StageOutputBuilder = Callable[[dict[str, Any]], dict[str, Any]] @@ -11,6 +12,7 @@ class PersonaDefinition: persona_id: str file_path: str + package_name: str | None = None @dataclass(frozen=True) @@ -39,6 +41,7 @@ class TaskTypeDefinition: PERSONA_REGISTRY: dict[str, PersonaDefinition] = {} STAGE_HANDLER_REGISTRY: dict[str, StageHandlerDefinition] = {} TASK_REGISTRY: dict[str, TaskTypeDefinition] = {} +LOADED_REGISTRY_PACKAGES: tuple[str, ...] = () def register_persona(definition: PersonaDefinition) -> None: @@ -59,6 +62,26 @@ def persona_path_for(persona_id: str) -> str: return get_persona_definition(persona_id).file_path +def resolve_persona_path(persona_id: str, project_root: str | Path) -> Path: + definition = get_persona_definition(persona_id) + persona_path = Path(definition.file_path).expanduser() + if persona_path.is_absolute(): + return persona_path + if definition.package_name: + package = importlib.import_module(definition.package_name) + package_paths = list(getattr(package, "__path__", [])) + if package_paths: + return Path(package_paths[0]) / persona_path + package_file = getattr(package, "__file__", None) + if package_file is not None: + return Path(package_file).resolve().parent / persona_path + return Path(project_root) / persona_path + + +def registered_persona_ids() -> frozenset[str]: + return frozenset(PERSONA_REGISTRY) + + def register_stage_handler(definition: StageHandlerDefinition) -> None: STAGE_HANDLER_REGISTRY[definition.handler_id] = definition @@ -77,6 +100,16 @@ def registered_stage_handler_ids() -> frozenset[str]: return frozenset(STAGE_HANDLER_REGISTRY) +def reset_registry() -> None: + PERSONA_REGISTRY.clear() + STAGE_HANDLER_REGISTRY.clear() + TASK_REGISTRY.clear() + + +def loaded_registry_packages() -> tuple[str, ...]: + return LOADED_REGISTRY_PACKAGES + + def register_task_type(definition: TaskTypeDefinition) -> None: TASK_REGISTRY[definition.task_type] = definition @@ -160,14 +193,51 @@ def build_deliverable(task_type: str, context: Any) -> dict[str, Any]: return deliverable -def load_builtin_registry() -> None: - importlib.import_module("demos.stage_handlers") - from demos.task_types import BUILTIN_PERSONAS, BUILTIN_TASK_TYPES - - for persona in BUILTIN_PERSONAS: +def load_registry( + extra_packages: tuple[str, ...] = (), + reset: bool = True, +) -> tuple[str, ...]: + from demos.discovery import collect_module_exports, configured_package_names + + package_names = configured_package_names( + default_packages=("demos",), + extra_packages=extra_packages, + ) + if reset: + reset_registry() + + handlers = collect_module_exports( + package_names, + "STAGE_HANDLER_DEFINITIONS", + exact_names=("stage_handlers",), + prefixes=("stage_handlers_",), + ) + personas = collect_module_exports( + package_names, + "PERSONA_DEFINITIONS", + exact_names=("persona_definitions",), + prefixes=("persona_definitions_",), + ) + task_types = collect_module_exports( + package_names, + "TASK_TYPE_DEFINITIONS", + exact_names=("task_types",), + prefixes=("task_types_",), + ) + + for handler in handlers: + register_stage_handler(handler) + for persona in personas: register_persona(persona) - for task_type in BUILTIN_TASK_TYPES: + for task_type in task_types: register_task_type(task_type) + global LOADED_REGISTRY_PACKAGES + LOADED_REGISTRY_PACKAGES = package_names + return package_names + + +def load_builtin_registry() -> tuple[str, ...]: + return load_registry() -load_builtin_registry() +load_registry() diff --git a/demos/task_types.py b/demos/task_types.py index ea558fc..0aec595 100644 --- a/demos/task_types.py +++ b/demos/task_types.py @@ -1,6 +1,6 @@ from __future__ import annotations -from demos.task_registry import PersonaDefinition, StageDefinition, TaskTypeDefinition +from demos.task_registry import StageDefinition, TaskTypeDefinition COMMON_STAGE_SEQUENCE = ( @@ -27,23 +27,7 @@ ) -BUILTIN_PERSONAS = ( - PersonaDefinition( - persona_id="design_persona", - file_path="personas/design_persona.json", - ), - PersonaDefinition( - persona_id="research_persona", - file_path="personas/researcher_persona.json", - ), - PersonaDefinition( - persona_id="marketing_persona", - file_path="personas/marketing_persona.json", - ), -) - - -BUILTIN_TASK_TYPES = ( +TASK_TYPE_DEFINITIONS = ( TaskTypeDefinition( task_type="market_research", deliverable_type="market_strategy_report", @@ -75,3 +59,6 @@ }, ), ) + + +BUILTIN_TASK_TYPES = TASK_TYPE_DEFINITIONS