draft

Spec: F99 — Commitment-to-Task Conversion

Status: draft Source issue: lumi-feedback#21 — UC-04: Convert commitments into trackable tasks

1. Problem & CEO Job

See clearly + trust the signal. F09 records promises from chat and pulses, but an actionable promise without a Task has no accountable owner in the project timeline. F99 detects concrete, assignable commitments and proposes a Task through confirm-before-commit, avoiding a task form. Deduplication prevents the same work from being tracked twice.

2. User Stories & Acceptance Criteria

  • As a member, I want my commitments (“I’ll send it Friday”) to become proposals for trackable Tasks, so that I don’t have to manually create tasks for promises I’ve already made.
    • Given a pulse or chat message containing a commitment with a concrete action, named owner, and deadline, when Lumi extracts it, then it creates a Commitment (per F09) and also proposes a Task via SignalProposal with title derived from the action, owner set to the named person, due_date set to the deadline, and EvidenceSource linked.
    • Given a commitment with ambiguous action (“em xử lý nhé”), when Lumi processes it, then it records the Commitment but does NOT propose a Task — it asks for clarification.
  • As a PM/CEO, I want commitments that already correspond to existing Tasks to be linked rather than duplicated, so that the same work doesn’t appear in two places.
    • Given a commitment signal whose normalized title + owner + project matches an existing Task, when Lumi processes it, then it links the new Commitment’s EvidenceSource to the existing Task (via Task.evidence_sources M2M) without creating a new Task proposal.
    • Given a partial match (same owner + project, similar title), when Lumi processes it, then it proposes a Task but includes a “possible duplicate of Task ” note in the proposal payload — the user decides via accept/reject.
  • As a manager, I want to review proposed tasks from commitments before they enter the live task list, so that I catch wrong owners, incorrect deadlines, or duplicates.
    • Given a commitment-to-task proposal in SignalInbox, when I accept (optionally editing title/owner/due_date), then a live Task is created, the Commitment’s task FK is set, and the proposal status becomes accepted.
    • Given a proposal I reject with a reason, when I confirm, then no Task is created, the Commitment stands alone, and the rejection is recorded.
  • As a CEO, I want confirmed tasks from commitments to appear in Brief when overdue or blocked, so that I know which promises are at risk.
    • Given a confirmed Task (source_type=ai or source_type=pulse) linked to a Commitment, when the Task is overdue, then it appears in Brief §3 (overdue items) with the linked Commitment’s context. Every CEO-facing Brief item has EvidenceSource linkage (traceable to the commitment signal).

3. Scope Boundaries

  • In: Commitment-to-Task proposal pipeline (extraction → proposal → confirm → commit or reject); dedup logic (exact match = link; partial match = proposal with note); Commitment.task FK population on confirm; Task creation with source_type=ai via SignalProposal; Brief surfacing of confirmed commitment-derived Tasks.
  • Out: Bot channel input (scope: F91 assistant chat + only pulse per shared contract). AI resolving existing Tasks based on single-word signals (“xong”). Editing committed Tasks (Task CRUD exists separately — F05). Creating Commitment objects (already F09). Bulk proposal accept. Task deletion via proposal rejection (rejected proposal does not touch existing linked Tasks — only prevents creation). A separate “commitment-to-task queue” UI (SignalInbox is the review surface).

4. Data Model

No new models or fields. Reuses and connects:

  • operations.Commitment — Existing model (backend/apps/operations/models.py:7). The task FK (already on the model, nullable, related_name="commitments") is populated when the Task proposal is accepted. One Commitment can fulfill one Task; multiple Commitments can link to the same Task (e.g., same promise mentioned in different pulses).
  • work.Task — Existing model (backend/apps/work/models.py:122). When created from a commitment-to-task proposal: source_type="ai", title = extracted action, owner = extracted assignee, due_date = extracted deadline (if any, marked as Inference in metadata), project = extracted context, evidence_sources M2M links to the Commitment’s EvidenceSource.
    • description field stores an attribution note: "Created from commitment: {commitment.title}" plus the commitment UUID.
    • No new fields needed — the existing evidence_sources M2M, source_type, and owner/due_date cover the model contract.
  • signals.SignalProposal — Confirm-before-commit via proposal_type="task". Payload contains the extracted Task fields: {title, owner_name, owner_id?, due_date, project_id?, commitment_id, confidence_score}. The proposal metadata includes a possible_duplicate_of UUID field (populated when partial match is detected).
  • pulses.EvidenceSource — Same source links both the Commitment and the proposed Task. On dedupe (existing Task match), the EvidenceSource is added to the existing Task’s M2M without creating any new object.
  • Dedup logic (service layer, no new model): A deduplicate_commitment_task(commitment, extracted) function that queries Task.objects.filter(organization=org, owner=extracted_owner, title__icontains=extracted_action_keywords):
    • Exact match → link (populate Commitment.task + add EvidenceSource to existing Task). Returns “linked”.
    • Partial match → proposal with possible_duplicate_of set. Returns “proposed_with_duplicate_note”.
    • No match → proposal with empty possible_duplicate_of. Returns “proposed”.

Status transitions: Task todo → in_progress → done|cancelled (existing F05 lifecycle). No new Task statuses.

5. API Contract

MethodPathAuth/RoleRequestResponseErrors
POST/api/signals/capture/member{raw_text, title?, capture_channel, project_id?}SignalInboxItem400
GET/api/signals/inbox/member?status=captured&proposal_type=taskList[SignalInboxItem] with task proposals—
POST/api/signals/proposals/<id>/accept/manager{title?, description?, owner_id?, due_date?}{proposal, object_id, object_type}400, 409
POST/api/signals/proposals/<id>/reject/manager{reason?}proposal409
GET/api/commitments/member(unchanged)List[Commitment] with task FK populated—
GET/api/tasks/member?source_type=aiList[Task] including commitment-derived tasks—

No new endpoints. All operations use existing SignalInbox + SignalProposal endpoints plus Commitment/Task read endpoints. The extraction service (apps/ai/services/extraction.py) is extended with a _propose_task_from_commitment(commitment, extracted_data) function called after Commitment creation when the commitment implies a concrete action.

6. UI / UX

  • No new route. Commitment-to-task proposals rendered via existing surfaces:
    • SignalInbox (/inbox) — Task proposals appear as SignalProposal cards with type=task. The card shows the proposed title, owner, due_date, project context, confidence, and (when populated) the “Possible duplicate of Task X” warning. Accept creates the Task; reject dismisses it.
    • Tasks list (/tasks) — Commitment-derived tasks appear with source_type=ai and an attribution chip linking to the source Commitment. The Task detail (existing) shows linked Commitments via task.commitments reverse relation.
    • Commitments list (/intel/commitments) — Commitments with a fulfilled task show a link to the Task. Commitments without a task but where extraction detected an actionable commitment show a “Propose task” suggestion badge.
    • Brief — Overdue commitment-derived Tasks appear in §3 with the linked Commitment context (owner, promise text, original deadline).
  • States: empty = no commitment-to-task proposals pending; loading skeletons per existing patterns; error = toast with retry.
  • i18n: new commitment_to_task namespace for proposal-specific strings. Reuses existing tasks, commitments, brief namespaces for rendered items.
  • DESIGN.md surfaces/tokens: per existing Task/BriefItem/Commitment rendering.
  • Assistant path (F91): Complete via conversation — user says “tạo task từ cam kết gửi báo giá” → Lumi creates SignalProposal, then confirms before committing. User says “bỏ qua” → rejects the proposal. User says “tạo luôn” → accepts with default extracted fields. The assistant surface is the primary path; SignalInbox is an optional review queu

7. Test Plan

#CaseTypeWhere
1Commitment with concrete action + owner + deadline → Commitment created + Task proposed via SignalProposalbackendtests/test_ai_extraction.py
2Commitment with ambiguous action → Commitment created, no Task proposedbackendtests/test_ai_extraction.py
3Exact dedup match (same normalized title + owner + project) → EvidenceSource linked to existing Task, no new Task proposalbackendtests/test_dedup.py
4Partial dedup match (same owner + project, similar title) → Task proposal with possible_duplicate_of setbackendtests/test_dedup.py
5No match → Task proposal without possible_duplicate_ofbackendtests/test_dedup.py
6Accept proposal → Task created, Commitment.task FK set, proposal status=acceptedbackendtests/test_signal_proposals.py
7Reject proposal → no Task created, Commitment.task FK stays null, proposal status=rejectedbackendtests/test_signal_proposals.py
8Accept with edited owner/due_date → Task uses edited valuesbackendtests/test_signal_proposals.py
9Overdue commitment-derived Task appears in Brief §3backendtests/test_brief_generation.py
10Cross-tenant: org B cannot access org A’s task proposalsbackendtests/test_multitenancy.py
11Commitment with existing task FK → Brief uses linked commitment contextfrontendfrontend/tests/e2e/lumi.spec.ts describe("Commitments")

8. Current Behavior

Commitment extraction is shipped (F09). The extraction pipeline (apps/ai/services/extraction.py) creates Commitment objects from pulse signals (and via apps/ai/services/message_signal.py for chat signals). The Commitment model has a nullable task FK (backend/apps/operations/models.py:33) that is never populated today — it exists as a forward-looking schema affordance. The Task model has source_type choices including ai and pulse but no code path creates Tasks from commitment signals. The SignalProposal mechanism exists with proposal_type="task" in its TYPE_CHOICES (backend/apps/signals/models.py:78) and accept/reject flow, but no extraction path creates Task proposals. Dedup logic for tasks does not exist.

F09 spec §12 open questions: “Linking a commitment to the Task that fulfills it exists in the model but has no UI.” This spec closes that gap with the extraction-to-Task pipeline.

9. Error States

#ErrorResponseNotes
ES1No concrete action extracted from commitment signalCommitment created (F09), but no Task proposal generated — extraction returns “no_action”Extraction service classifies commitment as status/state description vs. future action
ES2Extracted owner doesn’t match any OrganizationMemberTask proposal created with owner=null, owner_name_raw=extracted nameUser assigns owner during accept or later via Task edit
ES3Extracted deadline is inference onlyTask proposed with due_date set but proposal payload marks deadline_is_inference=TrueAccepting the proposal converts inference to confirmed fact; user can edit before accept
ES4Cross-tenant Task match in dedup queryDedup query scoped to organization_id — cross-tenant Tasks never matchedTenant scoping at service layer
ES5Duplicate proposal already pending for same signalExtraction skips proposal creation if a pending SignalProposal with proposal_type=task and matching payload_json.commitment_id already existsPrevents double-proposal race
ES6Commitment already has task FK setExtraction skips proposal creation — Commitment.task already fulfilledChecked before proposal generation

10. Diagrams & Docs Touched

  • backend/DIAGRAMS.md — No state machine changes (Task status unchanged, Commitment status unchanged). Add annotation that Commitment.task FK is populated by F99 proposal pipeline.
  • GLOSSARY.md — Add “Commitment-to-Task (F99)” entry.
  • docs/MVP_SCOPE.md — Add F99 registry row.
  • docs/AI_PIPELINE.md — Add commitment-to-task conversion step after commitment extraction.
  • docs/specs/done/F09-commitments.md — Update §12 to note that the Commitment.task FK gap is closed by F99.

11. Implementation Audit (2026-07-10)

Built in round 3 fleet deployment (2026-07-10). Shipped code:

ModuleStatusNotes
apps/ai/services/commitment_to_task.py✅ BuiltCore F99 service: propose_task_from_commitment, deduplicate_commitment_task, _is_actionable, DedupResult. 100% coverage in tests/api/test_commitment_to_task.py.
apps/ai/services/extraction.py✅ Extended_propose_tasks_from_created_commitments() called after keyword-path extraction, iterates created commitments and calls propose_task_from_commitment.
apps/ai/services/message_signal.py✅ ExtendedCalls propose_task_from_commitment after chat-derived commitment creation.
apps/signals/services.py✅ Extended_create_destination for proposal_type="task" now: sets source_type="ai", links EvidenceSource, populates Commitment.task FK from payload_json.commitment_id, handles owner/due_date overrides.
apps/signals/serializers.py✅ ExtendedProposalAcceptSerializer accepts owner_id (UUID) and due_date (Date) override fields.
apps/signals/views.py✅ ExtendedPasses owner_id/due_date overrides into proposal payload before accept_proposal.
tests/api/test_commitment_to_task.py✅ 23 testsSpec §7 test plan tests 1–11 + edge cases (ES5, ES6, ES2), cross-tenant isolation, API integration.

Design constraint: Commitment extraction remains synchronous (extract → commit). F99 adds a path after commitment creation: classify as actionable (has future-tense signal) vs. status-only (no future signal). Actionable commitments route through SignalProposal (propose → confirm → commit). F91 trust-level-0: the proposal is the confirmation gate.

Key design decisions implemented:

  • Actionability determined by presence of future-tense patterns in title/description (_is_actionable in commitment_to_task.py:47)
  • Dedup: exact title+owner match → link (no proposal); partial keyword+owner match → proposal with possible_duplicate_of; no match → clean proposal
  • Commitments from both pulse extraction and chat signals (message_signal.py) flow through F99
  • ProposalAcceptSerializer extended with owner_id and due_date for manager overrides during accept

12. Open Questions / Decisions

  • What is the classifier threshold for “actionable” vs. “status-only”? Decision: the existing LLM-based extraction (ADR-031) already classifies commitment signals. The prompt is extended to include an is_actionable boolean in the commitment extraction output. In keyword/fallback mode, any commitment that matches a future-tense pattern (“sẽ”, “will”, “mai”, “tuần sau”, “thứ X”) plus an action verb is considered actionable. Status-only patterns (“đang xử lý”, “đã làm”, status reports) are non-actionable.
  • Should the Task proposal carry a default or be empty until confirmed? Decision: proposal carries the extracted best-guess fields (title, owner, due_date, project). The user can accept as-is or edit (via ProposalAcceptSerializer.override_fields). This matches the existing SignalProposal pattern where the proposer sets defaults and the acceptor can override.
  • Does F99 require a new apps/ai/services/task_proposal.py module, or can it be a function in extraction.py? Decision: a new module apps/ai/services/commitment_to_task.py containing propose_task_from_commitment(commitment, extracted_data, membership) -> SignalProposal|None and deduplicate_commitment_task(commitment, extracted_data, org_id) -> DedupResult. Keeps commitment-to-task logic separate from core extraction.

13. Bug / Change Log

2026-07-10 — Round 3 fleet deployment: full implementation

Type: Feature Implementation

Summary:

  • Built apps/ai/services/commitment_to_task.py — core F99 service with propose_task_from_commitment, deduplicate_commitment_task, _is_actionable, DedupResult dataclass.
  • Extended extraction.py with _propose_tasks_from_created_commitments — called after pulse keyword extraction to propose tasks from actionable commitments.
  • Extended message_signal.py — calls F99 after chat-derived commitment creation.
  • Extended signals/services.py _create_destination for proposal_type="task": sets source_type="ai", links EvidenceSource, populates Commitment.task FK, handles override fields.
  • Extended ProposalAcceptSerializer with owner_id and due_date override fields.
  • 23 tests covering all 11 spec §7 test cases + edge cases (ES5, ES6, ES2) + cross-tenant isolation + API integration.

2026-07-11 — Frontend surface: CommitmentsPage task chip + SignalInbox duplicate warning

Type: Feature Implementation (Frontend)

Summary:

  • CommitmentsPage.tsx: Added ”→ Task” link chip in the title column chip row when row.task is set. Deep-links to /work/tasks?task=<taskId>, opening the task detail panel. Uses var(--color-success-green) token and ArrowRight icon. Added linked_task / linked_task_aria locale keys (en + vi authored; 60 other locales use English placeholder).
  • SignalInboxPage.tsx: Added duplicate task warning in ProposalRow. When proposal_type === 'task' and payload_json.possible_duplicate_of is truthy, shows a TriangleAlert amber warning (var(--signal-amber)) with role="alert". Added task_duplicate_warning locale key (en + vi authored; 60 other locales use English placeholder).
  • Unit tests: 5 new tests — CommitmentsPage.test.tsx (linked task chip shown/absent) + SignalInboxPage.test.tsx (duplicate warning for task with flag, absent without flag, absent for non-task proposals). All 37 tests pass.
  • Locale parity: node frontend/scripts/check-locale-parity.mjs → ✓ all 62 locales match.
  • TypeScript: zero errors. Design tokens: no stock palette violations.

2026-07-10 — Initial draft

Type: Feature Spec

Summary:

  • First draft of F99 commitment-to-task conversion spec from lumi-feedback#21 (UC-04).
  • Detected commitments with concrete actions propose Tasks via SignalProposal (confirm-before-commit, F91 trust level 0).
  • Deduplication against existing Tasks: exact match = link; partial match = proposal with note.
  • Populates the existing but unused Commitment.task FK.
  • Builds on existing input channels (assistant chat F91 + pulse) only.