Extracted duplicate _strip_markdown_fences / _parse_llm_json functions from 7 files into app/services/llm_utils.py. Two shared functions: - strip_markdown_fences(): fence stripping only - parse_llm_json(): fence stripping + JSON parse + error logging Files updated: flowpilot_engine, knowledge_flywheel, session_to_flow_service, ai_tree_generator_service, ai_fix_service, ai_chat_service, kb_conversion_service Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
244 lines
9.0 KiB
Python
244 lines
9.0 KiB
Python
"""Session-to-Flow AI generation service.
|
|
|
|
Converts a completed troubleshooting session into a reusable procedural
|
|
flow with fallback branches, powered by AI.
|
|
"""
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from typing import Any, Optional
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.ai_provider import get_ai_provider
|
|
from app.core.config import settings
|
|
from app.core.ai_tree_validator import validate_generated_procedural_steps
|
|
from app.services.llm_utils import parse_llm_json
|
|
from app.models.session import Session
|
|
from app.models.tree import Tree
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# AI system prompt for session-to-flow conversion
|
|
SESSION_TO_FLOW_SYSTEM_PROMPT = """You are an expert MSP engineer and IT process documentation specialist.
|
|
|
|
Your task is to convert a completed IT troubleshooting session into a reusable procedural flow with optional fallback branches.
|
|
|
|
You will receive:
|
|
- The session outcome and engineer notes
|
|
- An ordered list of decisions the engineer made (questions/answers, actions, notes, command output)
|
|
- The original troubleshooting tree structure (for context on alternative paths)
|
|
- Optional PSA ticket context
|
|
|
|
Generate a procedural flow that can be replicated for similar issues in the future. Each step should:
|
|
1. Be concrete and actionable — include exact commands, paths, or config values
|
|
2. Have a clear verification criterion
|
|
3. Include 1-3 fallback_steps per step (alternatives to try if the primary action fails)
|
|
4. End with a procedure_end step summarizing the resolution
|
|
|
|
Return ONLY a valid JSON object with this exact structure:
|
|
{
|
|
"name": "Short descriptive title (5-10 words)",
|
|
"description": "2-3 sentence description of what this flow resolves",
|
|
"tags": ["tag1", "tag2"],
|
|
"steps": [
|
|
{
|
|
"id": "step-1",
|
|
"type": "procedure_step",
|
|
"title": "Step title",
|
|
"description": "Detailed instructions with exact commands/paths",
|
|
"content_type": "text",
|
|
"fallback_steps": [
|
|
{
|
|
"id": "step-1-fb-1",
|
|
"type": "procedure_step",
|
|
"title": "Alternative: ...",
|
|
"description": "Alternative approach if primary step fails",
|
|
"content_type": "text"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": "step-end",
|
|
"type": "procedure_end",
|
|
"title": "Resolution Complete",
|
|
"description": "Summary of what was resolved and any follow-up actions"
|
|
}
|
|
]
|
|
}
|
|
|
|
Rules:
|
|
- Use unique string IDs for all steps (e.g. "step-1", "step-2", "step-1-fb-1")
|
|
- Include 3-10 procedure_step entries before the procedure_end
|
|
- Each step should be 1 concrete action, not a vague suggestion
|
|
- Fallback steps use the same schema as procedure_steps but represent alternative approaches
|
|
- Tags should be 2-5 relevant keywords (technology, vendor, symptom)
|
|
- Do NOT wrap JSON in markdown code fences
|
|
- Return only valid JSON, nothing else
|
|
"""
|
|
|
|
|
|
|
|
|
|
def _build_session_context(session: Session, tree: Optional[Tree]) -> str:
|
|
"""Build a context string from session data for the AI prompt."""
|
|
parts: list[str] = []
|
|
|
|
# Flow info
|
|
tree_name = session.tree_snapshot.get("name", "Unknown Flow") if session.tree_snapshot else "Unknown Flow"
|
|
parts.append(f"Flow: {tree_name}")
|
|
parts.append(f"Outcome: {session.outcome or 'Unknown'}")
|
|
|
|
if session.outcome_notes:
|
|
parts.append(f"Outcome Notes: {session.outcome_notes}")
|
|
|
|
# Session decisions (the troubleshooting path taken)
|
|
if session.decisions:
|
|
parts.append("\n--- TROUBLESHOOTING PATH ---")
|
|
for i, decision in enumerate(session.decisions):
|
|
step_parts: list[str] = [f"\nStep {i + 1}:"]
|
|
if decision.get("question"):
|
|
step_parts.append(f" Question: {decision['question']}")
|
|
if decision.get("answer"):
|
|
step_parts.append(f" Answer: {decision['answer']}")
|
|
if decision.get("action_performed"):
|
|
step_parts.append(f" Action: {decision['action_performed']}")
|
|
if decision.get("notes"):
|
|
step_parts.append(f" Notes: {decision['notes']}")
|
|
if decision.get("command_output"):
|
|
# Truncate long command output
|
|
output = decision["command_output"]
|
|
if len(output) > 500:
|
|
output = output[:500] + "... [truncated]"
|
|
step_parts.append(f" Command Output: {output}")
|
|
parts.append("\n".join(step_parts))
|
|
|
|
# Scratchpad
|
|
if session.scratchpad and session.scratchpad.strip():
|
|
parts.append(f"\n--- ENGINEER SCRATCHPAD ---\n{session.scratchpad[:1000]}")
|
|
|
|
# Original tree structure (for branch context, truncated)
|
|
if tree and tree.tree_structure:
|
|
tree_json = json.dumps(tree.tree_structure, indent=None)
|
|
if len(tree_json) > 3000:
|
|
tree_json = tree_json[:3000] + "... [truncated]"
|
|
parts.append(f"\n--- ORIGINAL TREE STRUCTURE (for alternative paths) ---\n{tree_json}")
|
|
elif session.tree_snapshot:
|
|
snapshot_json = json.dumps(session.tree_snapshot, indent=None)
|
|
if len(snapshot_json) > 3000:
|
|
snapshot_json = snapshot_json[:3000] + "... [truncated]"
|
|
parts.append(f"\n--- TREE SNAPSHOT (for alternative paths) ---\n{snapshot_json}")
|
|
|
|
return "\n".join(parts)
|
|
|
|
|
|
async def generate_flow_from_session(
|
|
session_id: str,
|
|
user_id: UUID,
|
|
account_id: UUID,
|
|
db: AsyncSession,
|
|
) -> dict[str, Any]:
|
|
"""Generate a procedural flow from a completed session.
|
|
|
|
Returns a dict with keys: name, description, tree_type, tags, tree_structure.
|
|
Raises ValueError on validation failures, Exception on AI/DB errors.
|
|
"""
|
|
# Load the session
|
|
session_uuid = UUID(session_id) if isinstance(session_id, str) else session_id
|
|
result = await db.execute(
|
|
select(Session).where(
|
|
Session.id == session_uuid,
|
|
Session.user_id == user_id,
|
|
)
|
|
)
|
|
session = result.scalar_one_or_none()
|
|
if not session:
|
|
raise ValueError(f"Session '{session_id}' not found or access denied")
|
|
|
|
# Load the original tree for branch context
|
|
tree: Optional[Tree] = None
|
|
if session.tree_id:
|
|
tree_result = await db.execute(
|
|
select(Tree).where(Tree.id == session.tree_id)
|
|
)
|
|
tree = tree_result.scalar_one_or_none()
|
|
|
|
# Build session context
|
|
session_context = _build_session_context(session, tree)
|
|
|
|
# Optionally fetch PSA ticket context
|
|
psa_context = ""
|
|
if session.psa_ticket_id and session.psa_connection_id:
|
|
try:
|
|
from app.services.psa.registry import get_provider_for_account
|
|
from app.services.psa.ticket_context import format_ticket_context_for_prompt
|
|
|
|
psa_provider = await get_provider_for_account(account_id, db)
|
|
connection_id = str(session.psa_connection_id)
|
|
ticket_ctx = await psa_provider.get_ticket_context(
|
|
ticket_id=int(session.psa_ticket_id),
|
|
connection_id=connection_id,
|
|
)
|
|
psa_context = "\n\n--- PSA TICKET CONTEXT ---\n" + format_ticket_context_for_prompt(ticket_ctx)
|
|
except Exception as psa_err:
|
|
logger.warning(
|
|
"Failed to fetch PSA ticket context for session-to-flow (session=%s, ticket=%s): %s",
|
|
session_id,
|
|
session.psa_ticket_id,
|
|
psa_err,
|
|
)
|
|
|
|
# Build user message
|
|
user_message = (
|
|
"Please convert the following completed troubleshooting session into a reusable procedural flow:\n\n"
|
|
f"{session_context}"
|
|
f"{psa_context}"
|
|
)
|
|
|
|
# Call AI
|
|
model = settings.get_model_for_action("generate_steps")
|
|
provider = get_ai_provider(model=model)
|
|
|
|
raw_text, input_tokens, output_tokens = await provider.generate_json(
|
|
system_prompt=SESSION_TO_FLOW_SYSTEM_PROMPT,
|
|
messages=[{"role": "user", "content": user_message}],
|
|
max_tokens=4096,
|
|
)
|
|
|
|
logger.info(
|
|
"session_to_flow AI response (tokens in=%d out=%d, session=%s)",
|
|
input_tokens,
|
|
output_tokens,
|
|
session_id,
|
|
)
|
|
|
|
# Strip markdown fences and parse JSON
|
|
generated = parse_llm_json(raw_text)
|
|
|
|
# Validate the generated steps
|
|
val_errors = validate_generated_procedural_steps(generated)
|
|
if val_errors:
|
|
raise ValueError(f"Generated flow failed validation: {'; '.join(val_errors)}")
|
|
|
|
# Ensure procedure_end exists; add if missing
|
|
steps = generated.get("steps", [])
|
|
has_end = any(s.get("type") == "procedure_end" for s in steps)
|
|
if not has_end:
|
|
steps.append({
|
|
"id": f"step-end-{uuid.uuid4().hex[:8]}",
|
|
"type": "procedure_end",
|
|
"title": "Procedure Complete",
|
|
"description": "All steps completed successfully.",
|
|
})
|
|
generated["steps"] = steps
|
|
|
|
return {
|
|
"name": generated.get("name", "AI-Generated Flow"),
|
|
"description": generated.get("description", ""),
|
|
"tree_type": "procedural",
|
|
"tags": generated.get("tags", []),
|
|
"tree_structure": {"steps": steps},
|
|
}
|