# Incident Action Import Row-Level Extraction

## Introduction

This document proposes changing incident postmortem action import so that one Action Items table row produces exactly one local incident todo action. The imported assignee should come from the row's `Assignee` column only, not from people mentioned inside the `Item` content.

## Motivation or Background

The current postmortem import flow extracts action items from the Feishu postmortem document with an LLM and then applies a second backend split step. This behavior is intentional in the existing prompt:

- `buildPostMortemActionExtractionPrompt()` tells the LLM to split multiple line-separated action statements in one `Item` cell into multiple JSON objects.
- `parseExtractedPostMortemActions()` calls `splitCompoundExtractedActions()` after JSON parsing.
- `splitCompoundExtractedActions()` can split one extracted action by description lines and copy row-level fields, including `linked_jira_issue`, to every split action.
- `resolveExtractedActionOwners()` merges owners from extracted owner fields with `@lark_user_id:<id>` mentions found in the title and description.

This creates confusing imported data when a single document row contains several lines under one `Item` cell and a single TCOCP link. The UI then displays multiple local actions that all point to the same TCOCP issue. It looks like duplicated work even though the source document intended one row-level action record.

The desired behavior is:

- A row in the Action Items table corresponds to one local incident todo action.
- The action assignee is the row's `Assignee` cell.
- Mentions inside the `Item` cell are treated as content context only and must not become action owners.
- Row-level TCOCP/Jira, priority, type, deadline, and status remain attached to that one imported action.

## Current State

Backend code:

- `pkg/service/incident/postmortem_action_import.go`
  - `CreatePostMortemToDoActionsFromFeishuDoc()` downloads Feishu plain text, extracts the Action Items section, calls `extractPostMortemActions()`, creates local todo actions, and ensures TCOCP linkage.
  - `buildPostMortemActionExtractionPrompt()` currently instructs the LLM to split one table row into multiple actions when the `Item` cell contains multiple action statements.
  - `parseExtractedPostMortemActions()` currently returns `splitCompoundExtractedActions(actions)`.
  - `splitCompoundExtractedActions()` performs a deterministic post-processing split and preserves row-level Jira/status fields on each split item.
  - `resolveExtractedActionOwners()` resolves owner emails plus Lark IDs from `OwnerLarkUserIDs`, title mentions, and description mentions.

Frontend code:

- `management-portal-ui/src/pages/Incidents/IncidentPostMortem/index.tsx`
  - The page lists whatever actions the backend returns.
  - It displays assignees, status, deadline, priority, TCOCP creation time, title, description, and linked Jira.
  - No frontend parsing is involved, so the confusing duplicate-looking actions originate from backend import data.

## Detailed Design

### Extraction Contract

Change the LLM extraction contract from action-line level to table-row level:

```json
[
  {
    "title": "short row-level action title",
    "description": "full Item cell content",
    "owner_emails": ["owner@example.com"],
    "owner_lark_user_ids": ["lark user id from Assignee cell only"],
    "deadline": "YYYY-MM-DD or empty",
    "type": "one of Mitigation SOP, New alert/metric/log, Operation SOP, Healthcheck, ConfigChange",
    "priority": "original Priority cell value, e.g. P0, P1, P2, P3, empty if missing",
    "linked_jira_issue": "Jira key or URL from this row if present, empty otherwise",
    "status": "open|in_progress|done or empty"
  }
]
```

Rules to encode in `buildPostMortemActionExtractionPrompt()`:

- For an Action Items table, produce exactly one JSON object per data row.
- Do not split a row because the `Item` cell contains multiple lines, bullets, labels, or mentions.
- `title` should be a concise summary of the row's `Item` cell.
- `description` should preserve the full row-level `Item` cell content, including line breaks when useful.
- `owner_emails` and `owner_lark_user_ids` must come only from the `Assignee` cell.
- Mentions in the `Item` cell must not be copied into owner fields.
- `linked_jira_issue`, `priority`, `type`, `deadline`, and `status` must come from the same row only.
- If the section has a non-table list, keep the existing best-effort behavior, but do not split one bullet into several actions.

### Backend Parser Changes

Remove the post-LLM split from `parseExtractedPostMortemActions()`:

```go
func parseExtractedPostMortemActions(raw string) ([]extractedPostMortemAction, error) {
    trimmed, err := extractLLMJSONArray(raw)
    if err != nil {
        return nil, err
    }
    var actions []extractedPostMortemAction
    if err := json.Unmarshal([]byte(trimmed), &actions); err != nil {
        return nil, fmt.Errorf("parse llm extracted todo actions failed: %w", err)
    }
    return actions, nil
}
```

`splitCompoundExtractedActions()`, `splitActionDescriptionLines()`, `isLabelLine()`, and `isOnlyLarkMentions()` become obsolete for import action creation. They can be removed unless another caller is introduced.

`stripLarkUserMentions()` should still be used for `title` if product wants titles without raw Lark mention tokens. For `description`, prefer preserving the original item content except for internal mention tokens if they render poorly in the UI. This is a product choice; it does not affect owner selection.

### Assignee Resolution

Change `resolveExtractedActionOwners()` so it resolves only extracted owner fields:

```go
func (s *incidentService) resolveExtractedActionOwners(ctx context.Context, action extractedPostMortemAction) []string {
    // Use action.OwnerEmails and action.OwnerLarkUserIDs only.
    // Do not scan action.Title or action.Description for Lark mentions.
}
```

This makes the table `Assignee` cell the single source of truth for imported local action owners.

Important behavior:

- Duplicate owner emails should still be de-duplicated.
- `@lark_user_id:<id>` prefixes in owner fields should still be accepted for compatibility with the LLM output.
- If owner resolution fails, log and skip that owner, preserving the current fault-tolerant import behavior.
- Item-cell mentions may remain in `description`, but they are not assignees.

### TCOCP Handling

Keep the existing TCOCP flow:

```text
extract one row-level action
  -> create one local todo action
  -> normalize or create linked TCOCP
  -> sync existing TCOCP fields when applicable
```

With row-level extraction, a row with `linked_jira_issue = TCOCP-123` creates one local action linked to `TCOCP-123`, instead of multiple local actions linked to the same issue.

Do not deduplicate by TCOCP globally. Two separate source rows may intentionally refer to the same TCOCP. The invariant is row-to-action, not TCOCP-to-action.

### Frontend Impact

No frontend implementation change is required for this fix. The Incident Postmortem page already renders the backend action list directly. Once backend import creates one action per row, the page will naturally stop showing multiple action entries for a single source row.

Optional later UI improvements:

- Show a compact linked Jira/TCOCP column more prominently.
- Display multiline descriptions with better formatting if row-level descriptions become longer.
- Add a duplicate TCOCP warning only if users still need help distinguishing intentionally shared TCOCP links.

## Test Design

### Functional Tests

Update or add backend unit tests in `pkg/service/incident/postmortem_action_import_test.go`:

- `TestParseExtractedPostMortemActionsDoesNotSplitRowLevelAction`
  - Input JSON has one action with a multiline description and one `TCOCP-*`.
  - Expected output length is `1`.
  - Expected linked Jira remains on that single action.
- `TestResolveExtractedActionOwnersIgnoresItemMentions`
  - `OwnerEmails` or `OwnerLarkUserIDs` contains the assignee.
  - `Description` contains another `@lark_user_id:<id>`.
  - Expected owners include only the Assignee-cell owner.
- `TestPostMortemActionExtractionPromptUsesOneActionPerRow`
  - Assert the prompt contains the row-level rules and does not contain the old split-row instructions.

Remove or rewrite `TestSplitCompoundExtractedActions`, because split behavior is no longer desired.

### Scenario Tests

Use a representative Action Items table:

| Priority | Type | Deadline | JIRA | Item | Assignee |
| --- | --- | --- | --- | --- | --- |
| P1 | Operation SOP | 2026-06-30 | TCOCP-123 | Process improvements:<br>1. update war room flow @A<br>2. update alert subscription @B | @Owner |

Expected import result:

- exactly one local action
- `linked_jira_issue = TCOCP-123`
- `persons = Owner`
- description contains both item lines
- neither `@A` nor `@B` becomes an assignee

### Compatibility Tests

- Existing documents with one action per row continue importing as before.
- Existing non-table Action Items sections continue best-effort importing.
- Existing manually created or edited actions are unaffected.
- Existing TCOCP sync behavior is unaffected except that fewer local rows are created from one source row.

## Impacts & Risks

Expected impacts:

- Imported actions align with the document table structure.
- The UI becomes less confusing because one row no longer expands into multiple local actions sharing the same TCOCP.
- Assignee ownership becomes more predictable because it follows the explicit table Assignee column.

Risks:

- Some historical users may have relied on multiline `Item` cells being split into separate local actions. They will now need separate table rows for separate tracked actions.
- A row-level action can have a longer description than before. The current frontend truncates descriptions with a tooltip, so this is acceptable but may be less convenient for dense rows.
- If the LLM fails to identify table rows from Feishu plain text, it may still produce line-level results. The prompt and parser tests reduce this risk, but the best long-term mitigation is a structured table parser when Feishu document APIs expose enough table metadata.

## Rollout Plan

1. Update the action extraction prompt to require one JSON object per Action Items table row.
2. Remove backend post-processing that splits multiline descriptions into multiple actions.
3. Restrict assignee resolution to `OwnerEmails` and `OwnerLarkUserIDs` extracted from the Assignee cell.
4. Update unit tests around parsing, prompt rules, and owner resolution.
5. Run targeted backend tests:

```bash
go test ./pkg/service/incident
```

6. No frontend deploy dependency beyond consuming the changed backend import results.

## Unresolved Questions

- Should item-cell Lark mention tokens be preserved in `description`, stripped, or converted to display names?
- For non-table Action Items sections, should one bullet always map to one action, or should the import require a table for deterministic row-level behavior?
- Should future import previews show the source row number to make row-to-action mapping auditable?
