Files
cowork-local/application/scheduling/ai_task_planner_service.py
T
vudt15andClaude Sonnet 5 69ab8e125b feat(R07): task repository, schedule calculator, Qt clock adapter, task/AI-planner services
Team Hoa, EPIC R07 (Scheduling & Workflow Runtime) - Team Hoa scope only
(R07-T01 -> T05; R07-T06 Co4EWorkflowService is Team Nam's).

- R07-T01: infrastructure/persistence/json/task_repository_impl.py wraps
  core/tasks.py's CRUD; core/tasks.py::save_task now writes through
  atomic_write.write_json (same durability fix as R06-T02, save_task was
  still doing a plain write_text).
- R07-T02: domain/tasks/schedule_calculator.py::ScheduleCalculator - the
  cron/interval/daily/weekly/monthly due-time math extracted from
  core/tasks.py, pure Python with is_holiday/make_cron injected so domain/
  never imports core (ADR-001 I2). core/tasks.py keeps its old function
  names as thin wrappers so every existing caller is unchanged. This was
  previously untested; now has its own unit suite.
- R07-T03: infrastructure/qt/qt_scheduler_clock.py::QtSchedulerClock wraps
  the QTimer TaskScheduler used to own directly, injected via a new
  `clock=` constructor param (defaults to a real one). Originally planned
  at platform/qt/... ; moved after confirming that name shadows the
  stdlib platform module (used by core/windows_sandbox_vm.py,
  core/appcontainer_sandbox.py) whenever the repo root is on sys.path.
  tests/fakes/fake_clock.py lets scheduler dispatch be tested tick-by-tick
  with no Qt event loop.
- R07-T04: application/scheduling/task_application_service.py centralizes
  run_now/duplicate/pause/delete/bulk_delete and the Kanban drag-drop
  business rules (move_to_status), currently only reachable by driving
  the real ui/schedule_task_tab.py widget.
- R07-T05: application/scheduling/ai_task_planner_service.py wraps
  core/ai_task_planner.py::plan_tasks and core/task_import.py::import_tasks
  as a seam, plus the attachment-stamping step that used to only exist
  inside the AI-create dialog's worker closure.

pytest: 328 pass (same 4 pre-existing failures as the R05/R06 baseline,
unrelated to this work - see docs/refactor/BaoCao_TeamHoa_R05_R06.md).
scripts/check_imports.py: PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:27:53 +09:00

95 lines
4.0 KiB
Python

"""AiTaskPlannerService - AI-generate / import task lists, outside the widget
(R07-T05).
``ui/schedule_task_tab.py``'s ``_AiCreateDialog`` already delegates the
actual planning to two existing pure functions —
``core/ai_task_planner.py::plan_tasks`` (natural-language description ->
task dicts, via the active provider) and
``core/task_import.py::import_tasks`` (Excel/CSV/JSON -> task dicts) — so
this service does not reimplement either. What it DOES own is one small
piece of business logic that currently only exists inside the dialog's
``AgentWorker`` job closure (``_generate``'s ``job()``): every AI-generated
task must carry the SAME file/link attachments the user attached to the
request, so they're available again at run time, not just visible to the
planner while it drafts the task list. Leaving that step trapped in a Qt
worker closure means it can only be exercised by driving the real dialog;
here it's a plain, independently testable method.
Pure Python: no Qt import. The provider is a constructor-injected factory
(``() -> Provider``, no arguments — matches ``AppContext.build_active_
provider``), the same dependency-inversion shape
``application/conversations/conversation_application_service.py`` (R04-T03)
uses for ITS provider factory.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Sequence, Union
ProviderFactory = Callable[[], Any]
CancelFn = Callable[[], bool]
class AiTaskPlannerService:
"""AI task generation + file/Excel/CSV/JSON import, for
``presentation/scheduling/ai_task_creator_dialog.py`` and
``ai_task_import_dialog.py`` (R08-T11) to call instead of importing
``core.ai_task_planner``/``core.task_import`` directly.
Args:
provider_factory: ``() -> Provider``. Production passes
``AppContext.build_active_provider``; tests pass a lambda
returning a :class:`FakeProvider`.
"""
def __init__(self, provider_factory: Optional[ProviderFactory] = None) -> None:
self._provider_factory = provider_factory
def plan(
self,
description: str,
*,
file_paths: Sequence[str] = (),
links: Sequence[str] = (),
provider: Any = None,
cancel: Optional[CancelFn] = None,
) -> List[Dict[str, Any]]:
"""Turn ``description`` into a list of NOT-yet-saved task dicts.
``provider`` overrides the constructor's factory for this one call
(useful for tests, or a caller that already resolved a provider);
omit it to use the injected factory. Raises ``RuntimeError`` when
no provider is available at all, or when the model's reply had no
parseable task list (same error ``core.ai_task_planner.plan_tasks``
already raises).
"""
resolved = provider if provider is not None else self._resolve_provider()
from cowork_local.core.ai_task_planner import plan_tasks
planned = plan_tasks(resolved, description, cancel=cancel)
# Attachments apply to EVERY generated task so they're still there
# when the task actually runs, not just while the planner drafts it
# (see module docstring — this used to only happen inside the
# dialog's worker closure).
for task in planned:
task["input"]["file_paths"] = list(file_paths)
task["input"]["links"] = list(links)
return planned
def import_file(self, path: Union[str, Path]) -> List[Dict[str, Any]]:
"""Excel/CSV/JSON -> NOT-yet-saved task dicts, auto-chained in file
order. Raises ``ValueError`` with a human-readable message on an
unusable/unsupported file (same contract
``core.task_import.import_tasks`` already has)."""
from cowork_local.core.task_import import import_tasks
return import_tasks(path)
def _resolve_provider(self) -> Any:
if self._provider_factory is None:
raise RuntimeError("No provider available to plan tasks.")
return self._provider_factory()
__all__ = ["AiTaskPlannerService"]