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). ThetaskFK (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_sourcesM2M links to the Commitment’s EvidenceSource.descriptionfield stores an attribution note:"Created from commitment: {commitment.title}"plus the commitment UUID.- No new fields needed — the existing
evidence_sourcesM2M,source_type, andowner/due_datecover the model contract.
signals.SignalProposal— Confirm-before-commit viaproposal_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 apossible_duplicate_ofUUID 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 queriesTask.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_ofset. 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
| Method | Path | Auth/Role | Request | Response | Errors |
|---|---|---|---|---|---|
| POST | /api/signals/capture/ | member | {raw_text, title?, capture_channel, project_id?} | SignalInboxItem | 400 |
| GET | /api/signals/inbox/ | member | ?status=captured&proposal_type=task | List[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?} | proposal | 409 |
| GET | /api/commitments/ | member | (unchanged) | List[Commitment] with task FK populated | — |
| GET | /api/tasks/ | member | ?source_type=ai | List[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 withtype=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 withsource_type=aiand an attribution chip linking to the source Commitment. The Task detail (existing) shows linked Commitments viatask.commitmentsreverse 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).
- SignalInbox (
- States: empty = no commitment-to-task proposals pending; loading skeletons per existing patterns; error = toast with retry.
- i18n: new
commitment_to_tasknamespace for proposal-specific strings. Reuses existingtasks,commitments,briefnamespaces 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
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Commitment with concrete action + owner + deadline → Commitment created + Task proposed via SignalProposal | backend | tests/test_ai_extraction.py |
| 2 | Commitment with ambiguous action → Commitment created, no Task proposed | backend | tests/test_ai_extraction.py |
| 3 | Exact dedup match (same normalized title + owner + project) → EvidenceSource linked to existing Task, no new Task proposal | backend | tests/test_dedup.py |
| 4 | Partial dedup match (same owner + project, similar title) → Task proposal with possible_duplicate_of set | backend | tests/test_dedup.py |
| 5 | No match → Task proposal without possible_duplicate_of | backend | tests/test_dedup.py |
| 6 | Accept proposal → Task created, Commitment.task FK set, proposal status=accepted | backend | tests/test_signal_proposals.py |
| 7 | Reject proposal → no Task created, Commitment.task FK stays null, proposal status=rejected | backend | tests/test_signal_proposals.py |
| 8 | Accept with edited owner/due_date → Task uses edited values | backend | tests/test_signal_proposals.py |
| 9 | Overdue commitment-derived Task appears in Brief §3 | backend | tests/test_brief_generation.py |
| 10 | Cross-tenant: org B cannot access org A’s task proposals | backend | tests/test_multitenancy.py |
| 11 | Commitment with existing task FK → Brief uses linked commitment context | frontend | frontend/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
| # | Error | Response | Notes |
|---|---|---|---|
| ES1 | No concrete action extracted from commitment signal | Commitment created (F09), but no Task proposal generated — extraction returns “no_action” | Extraction service classifies commitment as status/state description vs. future action |
| ES2 | Extracted owner doesn’t match any OrganizationMember | Task proposal created with owner=null, owner_name_raw=extracted name | User assigns owner during accept or later via Task edit |
| ES3 | Extracted deadline is inference only | Task proposed with due_date set but proposal payload marks deadline_is_inference=True | Accepting the proposal converts inference to confirmed fact; user can edit before accept |
| ES4 | Cross-tenant Task match in dedup query | Dedup query scoped to organization_id — cross-tenant Tasks never matched | Tenant scoping at service layer |
| ES5 | Duplicate proposal already pending for same signal | Extraction skips proposal creation if a pending SignalProposal with proposal_type=task and matching payload_json.commitment_id already exists | Prevents double-proposal race |
| ES6 | Commitment already has task FK set | Extraction skips proposal creation — Commitment.task already fulfilled | Checked 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:
| Module | Status | Notes |
|---|---|---|
apps/ai/services/commitment_to_task.py | ✅ Built | Core 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 | ✅ Extended | Calls 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 | ✅ Extended | ProposalAcceptSerializer accepts owner_id (UUID) and due_date (Date) override fields. |
apps/signals/views.py | ✅ Extended | Passes owner_id/due_date overrides into proposal payload before accept_proposal. |
tests/api/test_commitment_to_task.py | ✅ 23 tests | Spec §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_actionableincommitment_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
ProposalAcceptSerializerextended withowner_idanddue_datefor 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_actionableboolean 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.pymodule, or can it be a function in extraction.py? Decision: a new moduleapps/ai/services/commitment_to_task.pycontainingpropose_task_from_commitment(commitment, extracted_data, membership) -> SignalProposal|Noneanddeduplicate_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 withpropose_task_from_commitment,deduplicate_commitment_task,_is_actionable,DedupResultdataclass. - Extended
extraction.pywith_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_destinationforproposal_type="task": setssource_type="ai", linksEvidenceSource, populatesCommitment.taskFK, handles override fields. - Extended
ProposalAcceptSerializerwithowner_idanddue_dateoverride 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 whenrow.taskis set. Deep-links to/work/tasks?task=<taskId>, opening the task detail panel. Usesvar(--color-success-green)token andArrowRighticon. Addedlinked_task/linked_task_arialocale keys (en + vi authored; 60 other locales use English placeholder).SignalInboxPage.tsx: Added duplicate task warning inProposalRow. Whenproposal_type === 'task'andpayload_json.possible_duplicate_ofis truthy, shows aTriangleAlertamber warning (var(--signal-amber)) withrole="alert". Addedtask_duplicate_warninglocale 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.