diff --git a/backend/alembic/versions/034_add_next_steps_to_sessions.py b/backend/alembic/versions/034_add_next_steps_to_sessions.py new file mode 100644 index 00000000..3cc487ed --- /dev/null +++ b/backend/alembic/versions/034_add_next_steps_to_sessions.py @@ -0,0 +1,27 @@ +"""add next_steps to sessions + +Revision ID: 034 +Revises: 033 +Create Date: 2026-02-13 + +Adds next_steps TEXT column to sessions table. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '034' +down_revision: Union[str, None] = '033' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('sessions', sa.Column('next_steps', sa.Text(), server_default=sa.text("''"), nullable=True)) + + +def downgrade() -> None: + op.drop_column('sessions', 'next_steps') diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index 0e829fdd..68bdaf4c 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -236,6 +236,7 @@ async def complete_session( session.completed_at = datetime.now(timezone.utc) session.outcome = completion_data.outcome session.outcome_notes = completion_data.outcome_notes + session.next_steps = completion_data.next_steps await db.commit() await db.refresh(session) return session @@ -313,9 +314,10 @@ async def export_session( from app.services.variable_service import resolve_variables content = resolve_variables(content, session_vars) - # Mark as exported - session.exported = True - await db.commit() + # Only mark as exported if session is completed + if session.completed_at: + session.exported = True + await db.commit() return PlainTextResponse(content=content, media_type=media_type) diff --git a/backend/app/models/session.py b/backend/app/models/session.py index a8984d8a..91e40a50 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -56,6 +56,9 @@ class Session(Base): scratchpad: Mapped[Optional[str]] = mapped_column( Text, nullable=True, server_default=sa.text("''") ) + next_steps: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, server_default=sa.text("''") + ) # Relationships tree: Mapped["Tree"] = relationship("Tree", back_populates="sessions") diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py index f546b99b..83c2a550 100644 --- a/backend/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -50,6 +50,7 @@ class SessionUpdate(BaseModel): ticket_number: Optional[str] = Field(None, max_length=100) client_name: Optional[str] = Field(None, max_length=255) scratchpad: Optional[str] = None + next_steps: Optional[str] = None session_variables: Optional[dict[str, str]] = None @@ -65,14 +66,15 @@ class SessionResponse(BaseModel): completed_at: Optional[datetime] = None outcome: Optional[SessionOutcome] = None outcome_notes: Optional[str] = None + next_steps: str = "" ticket_number: Optional[str] = None client_name: Optional[str] = None exported: bool scratchpad: str = "" session_variables: dict[str, str] = Field(default_factory=dict) - @validator('scratchpad', pre=True, always=True) - def normalize_scratchpad(cls, v): + @validator('scratchpad', 'next_steps', pre=True, always=True) + def normalize_text_fields(cls, v): return v or "" class Config: @@ -83,11 +85,19 @@ class SessionExport(BaseModel): format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$") include_timestamps: bool = True include_tree_info: bool = True + # Phase A + include_outcome_notes: bool = True + include_next_steps: bool = True + max_step_index: Optional[int] = Field(None, ge=1, description="1-based inclusive step cutoff") + # Phase B + include_summary: bool = False + detail_level: Literal["standard", "full"] = "standard" class SessionComplete(BaseModel): outcome: SessionOutcome outcome_notes: Optional[str] = None + next_steps: Optional[str] = None class ScratchpadUpdate(BaseModel): diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py index 1d7148a0..d38a92c4 100644 --- a/backend/app/services/export_service.py +++ b/backend/app/services/export_service.py @@ -90,6 +90,26 @@ def _get_command_output(decision: dict[str, Any]) -> str | None: return output if output else None +def _truncate_command_output(output: str, max_lines: int = 5, fmt: str = "text") -> str: + """Truncate command output to max_lines for standard detail level. + + Args: + fmt: One of "markdown", "text", "html", "psa" — controls suffix formatting. + """ + lines = output.splitlines() + if len(lines) <= max_lines: + return output + truncated = "\n".join(lines[:max_lines]) + count = len(lines) + if fmt == "markdown": + suffix = f"*(full output omitted — {count} lines)*" + elif fmt == "html": + suffix = f"(full output omitted — {count} lines)" + else: # text, psa + suffix = f"(full output omitted — {count} lines)" + return f"{truncated}\n{suffix}" + + def _find_node_commands(tree_snapshot: dict[str, Any], node_id: str) -> list[str]: """Find the commands list for a node in the tree snapshot.""" def _search(node: dict[str, Any]) -> list[str] | None: @@ -113,6 +133,42 @@ def _get_outcome_label(session: Session) -> str | None: return OUTCOME_LABELS.get(outcome, str(outcome).replace("_", " ").title()) +def _build_summary_fields(session: Session) -> dict[str, str]: + """Build auto-populated summary fields from session data. + + Empty fields are left blank — users fill them in via the editable preview. + """ + tree_name = session.tree_snapshot.get("name", "Troubleshooting Session") + tree_desc = session.tree_snapshot.get("description", "") + issue = f"{tree_name}: {tree_desc}" if tree_desc else tree_name + + if session.completed_at: + status = "Resolved" if getattr(session, "outcome", None) == "resolved" else \ + f"Completed — {_get_outcome_label(session) or 'Unknown'}" + else: + step_count = len(session.decisions) if session.decisions else 0 + status = f"In Progress — paused at step {step_count}" if step_count else "In Progress" + + _raw_notes = getattr(session, 'outcome_notes', None) + resolution = (_raw_notes if isinstance(_raw_notes, str) else '').strip() + + _raw_next = getattr(session, 'next_steps', None) + next_steps = (_raw_next if isinstance(_raw_next, str) else '').strip() + + return { + "issue": issue, + "impact": "", + "status": status, + "resolution": resolution, + "next_steps": next_steps, + } + + +def _escape_markdown_table(value: str) -> str: + """Escape value for use in a markdown table cell.""" + return value.replace("|", "\\|").replace("\n", " ") + + def generate_markdown_export(session: Session, options: SessionExport) -> str: """Generate markdown export.""" lines = [] @@ -137,6 +193,22 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str: lines.append("---") lines.append("") + if options.include_summary: + summary = _build_summary_fields(session) + esc = _escape_markdown_table + lines.append("## Summary") + lines.append("") + lines.append("| Field | Details |") + lines.append("|-------|---------|") + lines.append(f"| Issue | {esc(summary['issue'])} |") + lines.append(f"| Impact | {esc(summary['impact'])} |") + lines.append(f"| Status | {esc(summary['status'])} |") + lines.append(f"| Resolution | {esc(summary['resolution'])} |") + lines.append(f"| Next Steps | {esc(summary['next_steps'])} |") + lines.append("") + lines.append("---") + lines.append("") + # Scratchpad / Evidence section scratchpad = getattr(session, 'scratchpad', '') or '' if scratchpad.strip(): @@ -150,18 +222,28 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str: lines.append("## Troubleshooting Steps") lines.append("") - for i, decision in enumerate(session.decisions, 1): + decisions = session.decisions + if options.max_step_index is not None: + decisions = decisions[:options.max_step_index] + + for i, decision in enumerate(decisions, 1): question = decision.get("question") or decision.get("action_performed", "Step") answer = decision.get("answer", "") notes = decision.get("notes", "") duration_seconds = _get_step_duration_seconds(decision) - lines.append(f"### Step {i}: {question}") + is_custom = decision.get("node_id", "").startswith("custom-") + prefix = "[CUSTOM] " if is_custom else "" + lines.append(f"### Step {i}: {prefix}{question}") + if is_custom: + lines.append("*Custom step added by engineer*") if answer: lines.append(f"**Answer:** {answer}") if notes: lines.append(f"**Notes:** {notes}") if command_output := _get_command_output(decision): + if options.detail_level == "standard": + command_output = _truncate_command_output(command_output, fmt="markdown") commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", "")) if commands: lines.append(f"**Commands Run:** {', '.join(f'`{c}`' for c in commands)}") @@ -175,6 +257,28 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str: lines.append(f"*{decision['timestamp']}*") lines.append("") + # Resolution / Outcome Notes + _raw_notes = getattr(session, 'outcome_notes', None) + outcome_notes = _raw_notes if isinstance(_raw_notes, str) else '' + if outcome_notes.strip() and options.include_outcome_notes: + lines.append("---") + lines.append("") + lines.append("## Resolution") + lines.append("") + lines.append(outcome_notes.strip()) + lines.append("") + + # Next Steps + _raw_next = getattr(session, 'next_steps', None) + next_steps = _raw_next if isinstance(_raw_next, str) else '' + if next_steps.strip() and options.include_next_steps: + lines.append("---") + lines.append("") + lines.append("## Next Steps") + lines.append("") + lines.append(next_steps.strip()) + lines.append("") + return "\n".join(lines) @@ -200,6 +304,17 @@ def generate_text_export(session: Session, options: SessionExport) -> str: lines.append(f"Outcome: {outcome_label}") lines.append("") + if options.include_summary: + summary = _build_summary_fields(session) + lines.append("SUMMARY") + lines.append("-" * 20) + lines.append(f"Issue: {summary['issue']}") + lines.append(f"Impact: {summary['impact']}") + lines.append(f"Status: {summary['status']}") + lines.append(f"Resolution: {summary['resolution']}") + lines.append(f"Next Steps: {summary['next_steps']}") + lines.append("") + # Scratchpad / Evidence section scratchpad = getattr(session, 'scratchpad', '') or '' if scratchpad.strip(): @@ -211,18 +326,26 @@ def generate_text_export(session: Session, options: SessionExport) -> str: lines.append("TROUBLESHOOTING STEPS") lines.append("-" * 20) - for i, decision in enumerate(session.decisions, 1): + decisions = session.decisions + if options.max_step_index is not None: + decisions = decisions[:options.max_step_index] + + for i, decision in enumerate(decisions, 1): question = decision.get("question") or decision.get("action_performed", "Step") answer = decision.get("answer", "") notes = decision.get("notes", "") duration_seconds = _get_step_duration_seconds(decision) - lines.append(f"\n{i}. {question}") + is_custom = decision.get("node_id", "").startswith("custom-") + prefix = "[CUSTOM] " if is_custom else "" + lines.append(f"\n{i}. {prefix}{question}") if answer: lines.append(f" Answer: {answer}") if notes: lines.append(f" Notes: {notes}") if command_output := _get_command_output(decision): + if options.detail_level == "standard": + command_output = _truncate_command_output(command_output, fmt="text") commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", "")) if commands: lines.append(f" Commands Run: {', '.join(commands)}") @@ -232,6 +355,24 @@ def generate_text_export(session: Session, options: SessionExport) -> str: if duration_seconds is not None: lines.append(f" Duration: {_format_step_duration(duration_seconds)}") + # Resolution + _raw_notes = getattr(session, 'outcome_notes', None) + outcome_notes = _raw_notes if isinstance(_raw_notes, str) else '' + if outcome_notes.strip() and options.include_outcome_notes: + lines.append("") + lines.append("RESOLUTION") + lines.append("-" * 20) + lines.append(outcome_notes.strip()) + + # Next Steps + _raw_next = getattr(session, 'next_steps', None) + next_steps = _raw_next if isinstance(_raw_next, str) else '' + if next_steps.strip() and options.include_next_steps: + lines.append("") + lines.append("NEXT STEPS") + lines.append("-" * 20) + lines.append(next_steps.strip()) + return "\n".join(lines) @@ -272,6 +413,17 @@ def generate_html_export(session: Session, options: SessionExport) -> str: html_parts.append(f'
Outcome: {html.escape(outcome_label)}
') html_parts.append('') + if options.include_summary: + summary = _build_summary_fields(session) + html_parts.append('| {html.escape(label)} | ') + html_parts.append(f'{html.escape(value)} |
Answer: {answer}
') if notes: html_parts.append(f'Notes: {notes}
') if command_output := _get_command_output(decision): + if options.detail_level == "standard": + command_output = _truncate_command_output(command_output, fmt="html") commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", "")) if commands: cmd_html = ", ".join(f"{html.escape(c)}" for c in commands)
@@ -304,6 +464,20 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
html_parts.append(f'')
html_parts.append('