Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions lumen/ai/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
SpeedDial, TextAreaInput, TextInput, Typography,
)

from ..util import resolve_module_reference
from ..views.base import Panel, View
from .actor import (
Actor, ContextProvider, NullStep, TContext,
Expand All @@ -48,6 +49,28 @@
)


def _task_type_spec(obj: Any) -> str:
return f"{type(obj).__module__}.{type(obj).__name__}"


def _task_type_from_spec(type_spec: str) -> type[Actor]:
return resolve_module_reference(type_spec, component_type=Task)


def _ensure_jsonable(value: Any) -> Any:
"""
Best-effort conversion of values to JSON-serializable structures.
Intended primarily for chat `history` payloads.
"""
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, dict):
return {str(k): _ensure_jsonable(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_ensure_jsonable(v) for v in value]
return str(value)


class Task(Viewer):
"""
A `Task` defines a single unit of work that can be executed and rendered.
Expand Down Expand Up @@ -241,6 +264,88 @@ def reset(self):
self._view[:] = []
self.out_context.clear()

def to_spec(
self,
*,
include_views: bool = False,
include_context: bool = False,
include_artifacts: bool = False,
) -> dict[str, Any]:
"""
Serialize a Task to a declarative spec.

By default, this only captures the task definition (and `history`) and
excludes rendered `views`, execution state, and generated artifacts.
"""
params: dict[str, Any] = {}
for k in ("title", "instruction", "history", "abort_on_error"):
v = getattr(self, k)
pobj = self.param[k]
try:
equal = is_equal(v, pobj.default)
except Exception:
try:
equal = v == pobj.default
except Exception:
equal = False
if equal:
continue
if k == "history":
v = _ensure_jsonable(v)
params[k] = v

if include_context and "context" in self.param:
params["context"] = _ensure_jsonable(self.context)

if include_artifacts:
# Explicit opt-in only
params["status"] = self.status
params["out_context"] = _ensure_jsonable(self.out_context)

if include_views:
params["views"] = None

return {"type": _task_type_spec(self), **params}

@classmethod
def from_spec(
cls,
spec: dict[str, Any],
*,
actor_lookup: dict[str, Actor | Tool] | None = None,
**params
) -> Task:
"""
Materialize a Task from a spec.

Parameters
----------
actor_lookup:
Mapping used to resolve `ActorTask.actor` keys.
"""
spec = dict(spec)
type_spec = spec.get("type")
tasks = spec.get("tasks")
if type_spec:
task_type = _task_type_from_spec(type_spec)
else:
task_type = cls

if tasks is not None:
# Delegate to TaskGroup-like materialization
if hasattr(task_type, "_tasks"):
return task_type.from_spec(spec, actor_lookup=actor_lookup, **params) # type: ignore[misc]

if hasattr(task_type, "from_spec") and task_type is not cls:
return task_type.from_spec(spec, actor_lookup=actor_lookup, **params) # type: ignore[misc]

base_params = {k: v for k, v in spec.items() if k not in ("type", "tasks", "params")}
if "params" in spec and isinstance(spec["params"], dict):
# Backward compatibility with nested param format.
base_params = dict(spec["params"], **base_params)
materialized_params = dict(base_params, **params)
return task_type(**materialized_params) # type: ignore[call-arg]


class TaskGroup(Task):

Expand Down Expand Up @@ -606,6 +711,55 @@ def reset(self, start: int = 0):
task.reset()
self._populate_view()

def to_spec(
self,
*,
include_views: bool = False,
include_context: bool = False,
include_artifacts: bool = False,
) -> dict[str, Any]:
spec = super().to_spec(
include_views=include_views,
include_context=include_context,
include_artifacts=include_artifacts,
)
spec["tasks"] = [
t.to_spec(
include_views=include_views,
include_context=include_context,
include_artifacts=include_artifacts,
)
for t in self._tasks
]
return spec

@classmethod
def from_spec(
cls,
spec: dict[str, Any],
*,
actor_lookup: dict[str, Actor | Tool] | None = None,
**params
) -> TaskGroup:
spec = dict(spec)
type_spec = spec.get("type")
base_params = {k: v for k, v in spec.items() if k not in ("type", "tasks", "params")}
if "params" in spec and isinstance(spec["params"], dict):
# Backward compatibility with nested param format.
base_params = dict(spec["params"], **base_params)
params = dict(base_params, **params)
tasks_spec = list(spec.get("tasks") or [])

group_type = _task_type_from_spec(type_spec) if type_spec else cls
tasks: list[Task] = []
for ts in tasks_spec:
tasks.append(Task.from_spec(ts, actor_lookup=actor_lookup, context=params.get("context", {})))

obj = group_type(*tasks, **params) # type: ignore[call-arg]
for t in obj:
t.parent = obj
return obj

def to_notebook(self):
"""
Returns the notebook representation of the tasks.
Expand Down Expand Up @@ -814,6 +968,22 @@ def __init__(self, *tasks, **params):
if self.auto_execute:
pn.state.execute(self.execute)

def to_spec(
self,
*,
include_views: bool = False,
include_context: bool = False,
include_artifacts: bool = False,
) -> dict[str, Any]:
spec = super().to_spec(
include_views=include_views,
include_context=include_context,
include_artifacts=include_artifacts,
)
if self.auto_execute != self.param.auto_execute.default:
spec["auto_execute"] = self.auto_execute
return spec

def _init_view(self):
self._header_title = Typography(
self.param.title, variant="h1", margin=(0, 0, 0, 10)
Expand Down Expand Up @@ -1116,6 +1286,49 @@ def __init__(self, actor: Actor, **params):
if views and out_context:
self._add_outputs(views, out_context)

def to_spec(
self,
*,
include_views: bool = False,
include_context: bool = False,
include_artifacts: bool = False,
actor_key: str | None = None,
) -> dict[str, Any]:
spec = super().to_spec(
include_views=include_views,
include_context=include_context,
include_artifacts=include_artifacts,
)
# Store actor by lookup key; reconstruction requires an explicit mapping.
spec["actor"] = actor_key or type(self.actor).__name__
return spec

@classmethod
def from_spec(
cls,
spec: dict[str, Any],
*,
actor_lookup: dict[str, Actor | Tool] | None = None,
**params
) -> ActorTask:
spec = dict(spec)
base_params = {k: v for k, v in spec.items() if k not in ("type", "tasks", "params")}
if "params" in spec and isinstance(spec["params"], dict):
# Backward compatibility with nested param format.
base_params = dict(spec["params"], **base_params)
params = dict(base_params, **params)
actor_key = params.pop("actor", None)
if actor_key is None:
raise ValueError("ActorTask spec did not declare required 'actor' key.")
if actor_lookup is None or actor_key not in actor_lookup:
known = [] if actor_lookup is None else sorted(actor_lookup)
raise ValueError(
f"ActorTask spec references unknown actor {actor_key!r}. "
f"Provide actor_lookup with this key. Known keys: {known!r}"
)
actor = actor_lookup[actor_key]
return cls(actor, **params) # type: ignore[arg-type]

@property
def input_schema(self):
return self.actor.input_schema
Expand Down
53 changes: 52 additions & 1 deletion lumen/tests/ai/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from lumen.ai.actor import ContextModel
from lumen.ai.report import (
Action, Report, Section, TaskGroup, Typography,
Action, Actor, ActorTask, Report, Section, Task, TaskGroup, Typography,
)


Expand Down Expand Up @@ -369,3 +369,54 @@ async def test_report_to_notebook():

assert cell3['cell_type'] == 'markdown'
assert cell3['source'] == ["**Hello**"]


def test_taskgroup_spec_roundtrip_preserves_structure():
tg = TaskGroup(A(title="A1"), B(title="B1"), title="Seq", instruction="Do it")
tg.history = [{"role": "user", "content": "hi"}]
spec = tg.to_spec()

assert "tasks" in spec
assert "views" not in spec
assert "out_context" not in spec

new = Task.from_spec(spec)
assert isinstance(new, TaskGroup)
assert new.title == "Seq"
assert new.instruction == "Do it"
assert new.history == [{"role": "user", "content": "hi"}]
assert len(new) == 2
assert type(new[0]) is A
assert type(new[1]) is B


def test_report_spec_roundtrip_preserves_sections_and_title():
report = Report(Section(HelloAction(title="Hello"), title="S1"), title="MyReport")
report.history = [{"role": "user", "content": "build report"}]
spec = report.to_spec()

new = Task.from_spec(spec)
assert isinstance(new, Report)
assert new.title == "MyReport"
assert new.history == [{"role": "user", "content": "build report"}]
assert len(new) == 1
assert isinstance(new[0], Section)
assert new[0].title == "S1"


def test_actortask_spec_roundtrip_uses_actor_lookup():
class DummyActor(Actor):
async def respond(self, messages, context, **kwargs):
return [], {}

actor = DummyActor()
task = ActorTask(actor, title="T1", instruction="Run")
task.history = [{"role": "user", "content": "do"}]

spec = task.to_spec()
new = Task.from_spec(spec, actor_lookup={"DummyActor": actor})
assert isinstance(new, ActorTask)
assert new.actor is actor
assert new.title == "T1"
assert new.instruction == "Run"
assert new.history == [{"role": "user", "content": "do"}]
Loading