Merge feat/kb-accelerator into main
This commit was merged in pull request #104.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md - Patherly / ResolutionFlow Project Context
|
||||
|
||||
> **Last Updated:** March 3, 2026
|
||||
> **Last Updated:** March 11, 2026
|
||||
|
||||
---
|
||||
|
||||
@@ -348,6 +348,10 @@ navigate(`/trees/${newTree.id}/edit`)
|
||||
|
||||
**55. App shell height chain for full-height pages (tree editor, procedural editor):** The CSS Grid app shell (`app-shell`) → `.main-content` → page component chain must preserve height. `.main-content` is a grid cell with implicit height from `1fr`. Pages using React Flow or other full-height layouts need every wrapper div between `.main-content` and the canvas to either use `flex` + `flex-1` + `min-h-0` or explicit `h-full`. Adding ANY wrapper div (e.g., for animations, transitions) without proper height classes will collapse the canvas to 0.
|
||||
|
||||
**56. Railway backend service name is `patherly`:** Use `railway variables --service patherly --json` to get env vars. Production DB name is `railway` (not `patherly` or `resolutionflow`). Public Postgres proxy: `interchange.proxy.rlwy.net:45797`. Internal URL only reachable via `railway run`.
|
||||
|
||||
**57. Node field priority for display/context:** Nodes use different label fields by type — procedural steps use `title`+`description`, decision nodes use `question`, action/solution nodes use `title`. When reading a node's label generically, check: `title` → `question` → `description` → `content` → `label`. See `copilot_service.py` `_build_flow_context()`.
|
||||
|
||||
---
|
||||
|
||||
## RBAC & Permissions
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.models.assistant_chat import AssistantChat
|
||||
from app.models.survey_response import SurveyResponse
|
||||
from app.models.survey_invite import SurveyInvite
|
||||
from app.models.ai_suggestion import AISuggestion # noqa: F401
|
||||
from app.models.kb_import import KBImport, KBImportNode # noqa: F401
|
||||
from app.core.config import settings
|
||||
|
||||
# this is the Alembic Config object
|
||||
|
||||
79
backend/alembic/versions/054_add_kb_imports.py
Normal file
79
backend/alembic/versions/054_add_kb_imports.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""add kb_imports and kb_import_nodes tables
|
||||
|
||||
Revision ID: 054
|
||||
Revises: 053
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision = "054"
|
||||
down_revision = "053"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"kb_imports",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("source_filename", sa.String(500), nullable=True),
|
||||
sa.Column("source_format", sa.String(20), nullable=False),
|
||||
sa.Column("source_text", sa.Text, nullable=False),
|
||||
sa.Column("source_metadata", JSONB, nullable=True),
|
||||
sa.Column("target_type", sa.String(20), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="processing"),
|
||||
sa.Column("confidence_avg", sa.Float, nullable=True),
|
||||
sa.Column("error_message", sa.Text, nullable=True),
|
||||
sa.Column("processing_time_ms", sa.Integer, nullable=True),
|
||||
sa.Column("ai_tokens_input", sa.Integer, nullable=True),
|
||||
sa.Column("ai_tokens_output", sa.Integer, nullable=True),
|
||||
sa.Column("tree_id", UUID(as_uuid=True), sa.ForeignKey("trees.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("batch_id", UUID(as_uuid=True), nullable=True, index=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.CheckConstraint(
|
||||
"source_format IN ('txt', 'paste', 'docx', 'pdf', 'html', 'md')",
|
||||
name="ck_kb_imports_source_format",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"target_type IN ('troubleshooting', 'procedural')",
|
||||
name="ck_kb_imports_target_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('processing', 'ready', 'committed', 'failed')",
|
||||
name="ck_kb_imports_status",
|
||||
),
|
||||
)
|
||||
|
||||
op.create_index("ix_kb_imports_status", "kb_imports", ["status"])
|
||||
op.create_index("ix_kb_imports_created_at_desc", "kb_imports", [sa.text("created_at DESC")])
|
||||
|
||||
op.create_table(
|
||||
"kb_import_nodes",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("kb_import_id", UUID(as_uuid=True), sa.ForeignKey("kb_imports.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("node_order", sa.Integer, nullable=False),
|
||||
sa.Column("node_type", sa.String(20), nullable=False),
|
||||
sa.Column("content", JSONB, nullable=False),
|
||||
sa.Column("parent_node_id", UUID(as_uuid=True), sa.ForeignKey("kb_import_nodes.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("source_excerpt", sa.Text, nullable=True),
|
||||
sa.Column("confidence_score", sa.Float, nullable=False),
|
||||
sa.Column("user_edited", sa.Boolean, nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("user_approved", sa.Boolean, nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.CheckConstraint(
|
||||
"node_type IN ('question', 'resolution', 'step', 'section_header', 'warning', 'action')",
|
||||
name="ck_kb_import_nodes_node_type",
|
||||
),
|
||||
)
|
||||
|
||||
op.create_index("ix_kb_import_nodes_confidence", "kb_import_nodes", ["confidence_score"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("kb_import_nodes")
|
||||
op.drop_table("kb_imports")
|
||||
76
backend/alembic/versions/055_add_kb_plan_limits.py
Normal file
76
backend/alembic/versions/055_add_kb_plan_limits.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""add KB Accelerator columns to plan_limits
|
||||
|
||||
Revision ID: 055
|
||||
Revises: 054
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision = "055"
|
||||
down_revision = "054"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Add KB Accelerator columns to plan_limits
|
||||
op.add_column("plan_limits", sa.Column("kb_accelerator_enabled", sa.Boolean, nullable=False, server_default=sa.text("false")))
|
||||
op.add_column("plan_limits", sa.Column("kb_max_lifetime_conversions", sa.Integer, nullable=True))
|
||||
op.add_column("plan_limits", sa.Column("kb_batch_max_size", sa.Integer, nullable=True))
|
||||
op.add_column("plan_limits", sa.Column("kb_allowed_formats", JSONB, nullable=False, server_default=sa.text("'[\"txt\",\"paste\"]'::jsonb")))
|
||||
op.add_column("plan_limits", sa.Column("kb_detailed_analysis", sa.Boolean, nullable=False, server_default=sa.text("false")))
|
||||
op.add_column("plan_limits", sa.Column("kb_conversational_refinement", sa.Boolean, nullable=False, server_default=sa.text("false")))
|
||||
op.add_column("plan_limits", sa.Column("kb_step_library_matching", sa.Boolean, nullable=False, server_default=sa.text("false")))
|
||||
op.add_column("plan_limits", sa.Column("kb_history_limit", sa.Integer, nullable=True))
|
||||
|
||||
# Seed defaults for each plan tier
|
||||
op.execute("""
|
||||
UPDATE plan_limits SET
|
||||
kb_accelerator_enabled = true,
|
||||
kb_max_lifetime_conversions = 3,
|
||||
kb_batch_max_size = NULL,
|
||||
kb_allowed_formats = '["txt","paste"]'::jsonb,
|
||||
kb_detailed_analysis = false,
|
||||
kb_conversational_refinement = false,
|
||||
kb_step_library_matching = false,
|
||||
kb_history_limit = 3
|
||||
WHERE plan = 'free'
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
UPDATE plan_limits SET
|
||||
kb_accelerator_enabled = true,
|
||||
kb_max_lifetime_conversions = NULL,
|
||||
kb_batch_max_size = 5,
|
||||
kb_allowed_formats = '["txt","paste","docx","pdf","html","md"]'::jsonb,
|
||||
kb_detailed_analysis = true,
|
||||
kb_conversational_refinement = true,
|
||||
kb_step_library_matching = true,
|
||||
kb_history_limit = NULL
|
||||
WHERE plan = 'pro'
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
UPDATE plan_limits SET
|
||||
kb_accelerator_enabled = true,
|
||||
kb_max_lifetime_conversions = NULL,
|
||||
kb_batch_max_size = 10,
|
||||
kb_allowed_formats = '["txt","paste","docx","pdf","html","md"]'::jsonb,
|
||||
kb_detailed_analysis = true,
|
||||
kb_conversational_refinement = true,
|
||||
kb_step_library_matching = true,
|
||||
kb_history_limit = NULL
|
||||
WHERE plan = 'team'
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("plan_limits", "kb_history_limit")
|
||||
op.drop_column("plan_limits", "kb_step_library_matching")
|
||||
op.drop_column("plan_limits", "kb_conversational_refinement")
|
||||
op.drop_column("plan_limits", "kb_detailed_analysis")
|
||||
op.drop_column("plan_limits", "kb_allowed_formats")
|
||||
op.drop_column("plan_limits", "kb_batch_max_size")
|
||||
op.drop_column("plan_limits", "kb_max_lifetime_conversions")
|
||||
op.drop_column("plan_limits", "kb_accelerator_enabled")
|
||||
44
backend/alembic/versions/056_add_md_to_kb_allowed_formats.py
Normal file
44
backend/alembic/versions/056_add_md_to_kb_allowed_formats.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Add md to kb_allowed_formats defaults
|
||||
|
||||
Revision ID: 056
|
||||
Revises: 055
|
||||
Create Date: 2026-03-12
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "056"
|
||||
down_revision = "055"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Update server default for new rows
|
||||
op.alter_column(
|
||||
"plan_limits",
|
||||
"kb_allowed_formats",
|
||||
server_default=sa.text("'[\"txt\",\"paste\",\"md\"]'::jsonb"),
|
||||
)
|
||||
# Add "md" to existing rows that have the old default ["txt","paste"]
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE plan_limits
|
||||
SET kb_allowed_formats = kb_allowed_formats || '["md"]'::jsonb
|
||||
WHERE NOT kb_allowed_formats @> '"md"'::jsonb
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column(
|
||||
"plan_limits",
|
||||
"kb_allowed_formats",
|
||||
server_default=sa.text("'[\"txt\",\"paste\"]'::jsonb"),
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE plan_limits
|
||||
SET kb_allowed_formats = kb_allowed_formats - 'md'
|
||||
"""
|
||||
)
|
||||
31
backend/app/api/endpoints/beta_signup.py
Normal file
31
backend/app/api/endpoints/beta_signup.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Public beta signup endpoint — no auth required."""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from app.core.email import EmailService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/beta-signup", tags=["beta"])
|
||||
|
||||
|
||||
class BetaSignupRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class BetaSignupResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
@router.post("", response_model=BetaSignupResponse)
|
||||
async def beta_signup(data: BetaSignupRequest):
|
||||
"""Collect beta interest — sends notification to beta@resolutionflow.com."""
|
||||
sent = await EmailService.send_beta_signup_notification(data.email)
|
||||
if not sent:
|
||||
logger.warning("Beta signup recorded (email delivery skipped): %s", data.email)
|
||||
return BetaSignupResponse(
|
||||
success=True,
|
||||
message="Thanks! We'll be in touch with beta access details.",
|
||||
)
|
||||
958
backend/app/api/endpoints/kb_accelerator.py
Normal file
958
backend/app/api/endpoints/kb_accelerator.py
Normal file
@@ -0,0 +1,958 @@
|
||||
"""KB Accelerator endpoints.
|
||||
|
||||
Upload KB articles, convert to flows via AI, review, and commit.
|
||||
|
||||
POST /kb-accelerator/upload — Upload file or paste text
|
||||
GET /kb-accelerator/{id} — Get import with nodes
|
||||
GET /kb-accelerator — List imports for account
|
||||
POST /kb-accelerator/{id}/convert — Re-trigger AI conversion
|
||||
PATCH /kb-accelerator/{id}/nodes/{nid} — Edit a node
|
||||
POST /kb-accelerator/{id}/commit — Commit to flow library
|
||||
DELETE /kb-accelerator/{id} — Cancel/cleanup
|
||||
GET /kb-accelerator/quota — Plan entitlements + usage
|
||||
"""
|
||||
import logging
|
||||
import mimetypes
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, UploadFile, File, Form, status
|
||||
from sqlalchemy import select, func, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
|
||||
from app.core.config import settings
|
||||
from app.core.rate_limit import limiter
|
||||
from app.core.subscriptions import get_plan_limits
|
||||
from app.core.ai_quota_service import get_user_plan
|
||||
from app.core.ai_tree_validator import validate_generated_tree
|
||||
from app.core.tree_validation import validate_procedural_structure
|
||||
from app.core.kb_extraction_service import extract_text
|
||||
from app.core.kb_conversion_service import convert_document
|
||||
from app.models.kb_import import KBImport, KBImportNode
|
||||
from app.models.plan_limits import PlanLimits
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
from app.schemas.kb_accelerator import (
|
||||
KBUploadTextRequest,
|
||||
KBNodeEditRequest,
|
||||
KBCommitRequest,
|
||||
KBUploadResponse,
|
||||
KBImportResponse,
|
||||
KBImportNodeResponse,
|
||||
KBImportSummary,
|
||||
KBImportListResponse,
|
||||
KBCommitResponse,
|
||||
KBQuotaResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/kb-accelerator", tags=["kb-accelerator"])
|
||||
|
||||
# Max upload size: 10MB
|
||||
MAX_UPLOAD_SIZE = 10 * 1024 * 1024
|
||||
|
||||
ALLOWED_EXTENSIONS = {
|
||||
"txt": ["text/plain"],
|
||||
"md": ["text/markdown", "text/plain"],
|
||||
"docx": ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
|
||||
}
|
||||
|
||||
# Phase 2 formats (not yet enabled)
|
||||
PHASE2_EXTENSIONS = {
|
||||
"pdf": ["application/pdf"],
|
||||
"html": ["text/html"],
|
||||
}
|
||||
|
||||
|
||||
def _detect_format(filename: str) -> str | None:
|
||||
"""Detect source format from filename extension."""
|
||||
if not filename:
|
||||
return None
|
||||
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else None
|
||||
if ext in ALLOWED_EXTENSIONS or ext in PHASE2_EXTENSIONS:
|
||||
return ext
|
||||
return None
|
||||
|
||||
|
||||
async def _get_kb_limits(user: User, db: AsyncSession) -> PlanLimits | None:
|
||||
plan = await get_user_plan(user.account_id, db)
|
||||
return await get_plan_limits(plan, db)
|
||||
|
||||
|
||||
async def _check_kb_enabled(user: User, db: AsyncSession) -> PlanLimits:
|
||||
limits = await _get_kb_limits(user, db)
|
||||
if not limits or not limits.kb_accelerator_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="KB Accelerator is not available on your plan.",
|
||||
)
|
||||
return limits
|
||||
|
||||
|
||||
async def _check_lifetime_limit(user: User, limits: PlanLimits, db: AsyncSession) -> None:
|
||||
if limits.kb_max_lifetime_conversions is None:
|
||||
return # Unlimited
|
||||
count = await db.scalar(
|
||||
select(func.count(KBImport.id)).where(
|
||||
KBImport.account_id == user.account_id,
|
||||
KBImport.status == "committed",
|
||||
)
|
||||
) or 0
|
||||
if count >= limits.kb_max_lifetime_conversions:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"You have reached your lifetime limit of {limits.kb_max_lifetime_conversions} KB conversions. Upgrade your plan for unlimited conversions.",
|
||||
)
|
||||
|
||||
|
||||
async def _check_format_allowed(source_format: str, limits: PlanLimits) -> None:
|
||||
allowed = limits.kb_allowed_formats or ["txt", "paste", "md"]
|
||||
if source_format not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Format '{source_format}' is not available on your plan. Allowed: {', '.join(allowed)}",
|
||||
)
|
||||
|
||||
|
||||
async def _get_import_or_404(
|
||||
import_id: UUID, user: User, db: AsyncSession, *, load_nodes: bool = True
|
||||
) -> KBImport:
|
||||
query = select(KBImport).where(
|
||||
KBImport.id == import_id,
|
||||
KBImport.account_id == user.account_id,
|
||||
)
|
||||
if load_nodes:
|
||||
query = query.options(selectinload(KBImport.nodes))
|
||||
result = await db.execute(query)
|
||||
kb_import = result.scalar_one_or_none()
|
||||
if not kb_import:
|
||||
raise HTTPException(status_code=404, detail="KB import not found")
|
||||
return kb_import
|
||||
|
||||
|
||||
async def _run_conversion(import_id: UUID, db_url: str) -> None:
|
||||
"""Background task: run AI conversion on a KB import."""
|
||||
from app.core.database import async_session_maker
|
||||
async with async_session_maker() as db:
|
||||
result = await db.execute(
|
||||
select(KBImport).where(KBImport.id == import_id)
|
||||
)
|
||||
kb_import = result.scalar_one_or_none()
|
||||
if not kb_import or kb_import.status != "processing":
|
||||
return
|
||||
try:
|
||||
await convert_document(kb_import, db)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.error("Background KB conversion failed: %s", e)
|
||||
kb_import.status = "failed"
|
||||
kb_import.error_message = f"Conversion error: {str(e)}"
|
||||
await db.commit()
|
||||
|
||||
|
||||
def _serialize_import(kb_import: KBImport) -> dict:
|
||||
"""Serialize a KBImport to dict for response."""
|
||||
return {
|
||||
"id": kb_import.id,
|
||||
"account_id": kb_import.account_id,
|
||||
"created_by": kb_import.created_by,
|
||||
"source_filename": kb_import.source_filename,
|
||||
"source_format": kb_import.source_format,
|
||||
"source_text": kb_import.source_text,
|
||||
"source_metadata": kb_import.source_metadata,
|
||||
"target_type": kb_import.target_type,
|
||||
"status": kb_import.status,
|
||||
"confidence_avg": kb_import.confidence_avg,
|
||||
"error_message": kb_import.error_message,
|
||||
"processing_time_ms": kb_import.processing_time_ms,
|
||||
"ai_tokens_input": kb_import.ai_tokens_input,
|
||||
"ai_tokens_output": kb_import.ai_tokens_output,
|
||||
"tree_id": kb_import.tree_id,
|
||||
"nodes": [
|
||||
KBImportNodeResponse.model_validate(n) for n in kb_import.nodes
|
||||
] if kb_import.nodes else [],
|
||||
"created_at": kb_import.created_at.isoformat(),
|
||||
"updated_at": kb_import.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── Endpoints ──
|
||||
|
||||
|
||||
@router.get("/quota", response_model=KBQuotaResponse)
|
||||
async def get_quota(
|
||||
user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Get KB Accelerator entitlements and usage for the current account."""
|
||||
plan = await get_user_plan(user.account_id, db)
|
||||
limits = await get_plan_limits(plan, db)
|
||||
|
||||
committed_count = await db.scalar(
|
||||
select(func.count(KBImport.id)).where(
|
||||
KBImport.account_id == user.account_id,
|
||||
KBImport.status == "committed",
|
||||
)
|
||||
) or 0
|
||||
|
||||
if not limits:
|
||||
return KBQuotaResponse(
|
||||
plan=plan,
|
||||
kb_accelerator_enabled=False,
|
||||
lifetime_conversions_used=committed_count,
|
||||
lifetime_conversions_limit=0,
|
||||
allowed_formats=["txt", "paste", "md"],
|
||||
detailed_analysis=False,
|
||||
conversational_refinement=False,
|
||||
step_library_matching=False,
|
||||
history_limit=3,
|
||||
can_convert=False,
|
||||
)
|
||||
|
||||
can_convert = limits.kb_accelerator_enabled
|
||||
if limits.kb_max_lifetime_conversions is not None:
|
||||
can_convert = can_convert and committed_count < limits.kb_max_lifetime_conversions
|
||||
|
||||
return KBQuotaResponse(
|
||||
plan=plan,
|
||||
kb_accelerator_enabled=limits.kb_accelerator_enabled,
|
||||
lifetime_conversions_used=committed_count,
|
||||
lifetime_conversions_limit=limits.kb_max_lifetime_conversions,
|
||||
allowed_formats=limits.kb_allowed_formats or ["txt", "paste"],
|
||||
detailed_analysis=limits.kb_detailed_analysis,
|
||||
conversational_refinement=limits.kb_conversational_refinement,
|
||||
step_library_matching=limits.kb_step_library_matching,
|
||||
history_limit=limits.kb_history_limit,
|
||||
can_convert=can_convert,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload", response_model=KBUploadResponse, status_code=201)
|
||||
@limiter.limit("10/minute")
|
||||
async def upload_kb_article(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
file: Optional[UploadFile] = File(None),
|
||||
content: Optional[str] = Form(None),
|
||||
title: Optional[str] = Form(None),
|
||||
target_type: Optional[str] = Form(None),
|
||||
):
|
||||
"""Upload a KB article file or paste text for conversion."""
|
||||
if not settings.ai_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="AI is not configured.",
|
||||
)
|
||||
|
||||
limits = await _check_kb_enabled(user, db)
|
||||
await _check_lifetime_limit(user, limits, db)
|
||||
|
||||
# Determine source format and extract text
|
||||
if file and file.filename:
|
||||
source_format = _detect_format(file.filename)
|
||||
if not source_format:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported file format. Supported: {', '.join(ALLOWED_EXTENSIONS.keys())}",
|
||||
)
|
||||
await _check_format_allowed(source_format, limits)
|
||||
|
||||
file_bytes = await file.read()
|
||||
if len(file_bytes) > MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=413, detail="File exceeds 10MB limit.")
|
||||
if len(file_bytes) == 0:
|
||||
raise HTTPException(status_code=400, detail="Uploaded file is empty.")
|
||||
|
||||
source_filename = file.filename
|
||||
try:
|
||||
source_text, source_metadata = extract_text(file_bytes, source_format)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
elif content:
|
||||
source_format = "paste"
|
||||
await _check_format_allowed(source_format, limits)
|
||||
source_filename = title
|
||||
source_text = content.strip()
|
||||
source_metadata = None
|
||||
if len(source_text) < 10:
|
||||
raise HTTPException(status_code=400, detail="Content must be at least 10 characters.")
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Provide either a file or content text.")
|
||||
|
||||
# Validate target_type (required — frontend must specify)
|
||||
if not target_type:
|
||||
target_type = "troubleshooting"
|
||||
if target_type not in ("troubleshooting", "procedural"):
|
||||
raise HTTPException(status_code=400, detail="target_type must be 'troubleshooting' or 'procedural'.")
|
||||
|
||||
# Create KB import record
|
||||
kb_import = KBImport(
|
||||
account_id=user.account_id,
|
||||
created_by=user.id,
|
||||
source_filename=source_filename,
|
||||
source_format=source_format,
|
||||
source_text=source_text,
|
||||
source_metadata=source_metadata,
|
||||
target_type=target_type,
|
||||
status="processing",
|
||||
)
|
||||
db.add(kb_import)
|
||||
await db.flush()
|
||||
|
||||
# Trigger AI conversion in background
|
||||
background_tasks.add_task(_run_conversion, kb_import.id, settings.DATABASE_URL)
|
||||
await db.commit()
|
||||
|
||||
return KBUploadResponse(
|
||||
id=kb_import.id,
|
||||
status=kb_import.status,
|
||||
source_format=kb_import.source_format,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{import_id}", response_model=KBImportResponse)
|
||||
async def get_kb_import(
|
||||
import_id: UUID,
|
||||
user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Get a KB import with its generated nodes."""
|
||||
kb_import = await _get_import_or_404(import_id, user, db)
|
||||
return _serialize_import(kb_import)
|
||||
|
||||
|
||||
@router.get("", response_model=KBImportListResponse)
|
||||
async def list_kb_imports(
|
||||
user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
status_filter: Optional[str] = None,
|
||||
):
|
||||
"""List KB imports for the current account."""
|
||||
limits = await _get_kb_limits(user, db)
|
||||
history_limit = limits.kb_history_limit if limits else 3
|
||||
|
||||
query = select(KBImport).where(KBImport.account_id == user.account_id)
|
||||
count_query = select(func.count(KBImport.id)).where(KBImport.account_id == user.account_id)
|
||||
|
||||
if status_filter:
|
||||
query = query.where(KBImport.status == status_filter)
|
||||
count_query = count_query.where(KBImport.status == status_filter)
|
||||
|
||||
total = await db.scalar(count_query) or 0
|
||||
|
||||
query = query.order_by(KBImport.created_at.desc())
|
||||
|
||||
# Apply history limit for free tier
|
||||
effective_limit = limit
|
||||
if history_limit is not None:
|
||||
effective_limit = min(limit, history_limit - skip) if skip < history_limit else 0
|
||||
|
||||
if effective_limit <= 0:
|
||||
return KBImportListResponse(items=[], total=total, skip=skip, limit=limit)
|
||||
|
||||
query = query.offset(skip).limit(effective_limit)
|
||||
query = query.options(selectinload(KBImport.nodes))
|
||||
result = await db.execute(query)
|
||||
imports = result.scalars().all()
|
||||
|
||||
items = []
|
||||
for imp in imports:
|
||||
items.append(KBImportSummary(
|
||||
id=imp.id,
|
||||
source_filename=imp.source_filename,
|
||||
source_format=imp.source_format,
|
||||
target_type=imp.target_type,
|
||||
status=imp.status,
|
||||
confidence_avg=imp.confidence_avg,
|
||||
node_count=len(imp.nodes) if imp.nodes else 0,
|
||||
created_at=imp.created_at.isoformat(),
|
||||
))
|
||||
|
||||
return KBImportListResponse(items=items, total=total, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.post("/{import_id}/convert", response_model=KBUploadResponse)
|
||||
@limiter.limit("30/minute")
|
||||
async def reconvert(
|
||||
request: Request,
|
||||
import_id: UUID,
|
||||
background_tasks: BackgroundTasks,
|
||||
user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Re-trigger AI conversion on an existing import (retry/regenerate)."""
|
||||
if not settings.ai_enabled:
|
||||
raise HTTPException(status_code=503, detail="AI is not configured.")
|
||||
|
||||
kb_import = await _get_import_or_404(import_id, user, db, load_nodes=False)
|
||||
|
||||
if kb_import.status == "committed":
|
||||
raise HTTPException(status_code=400, detail="Cannot reconvert a committed import.")
|
||||
|
||||
# Delete existing nodes
|
||||
await db.execute(
|
||||
delete(KBImportNode).where(KBImportNode.kb_import_id == kb_import.id)
|
||||
)
|
||||
|
||||
kb_import.status = "processing"
|
||||
kb_import.error_message = None
|
||||
kb_import.confidence_avg = None
|
||||
await db.flush()
|
||||
|
||||
background_tasks.add_task(_run_conversion, kb_import.id, settings.DATABASE_URL)
|
||||
await db.commit()
|
||||
|
||||
return KBUploadResponse(
|
||||
id=kb_import.id, status="processing", source_format=kb_import.source_format
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{import_id}/nodes/{node_id}", response_model=KBImportNodeResponse)
|
||||
async def edit_node(
|
||||
import_id: UUID,
|
||||
node_id: UUID,
|
||||
data: KBNodeEditRequest,
|
||||
user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Edit a specific node in a KB import during review."""
|
||||
kb_import = await _get_import_or_404(import_id, user, db, load_nodes=False)
|
||||
if kb_import.status != "ready":
|
||||
raise HTTPException(status_code=400, detail="Import must be in 'ready' status to edit nodes.")
|
||||
|
||||
result = await db.execute(
|
||||
select(KBImportNode).where(
|
||||
KBImportNode.id == node_id,
|
||||
KBImportNode.kb_import_id == import_id,
|
||||
)
|
||||
)
|
||||
node = result.scalar_one_or_none()
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
op = data.operation
|
||||
|
||||
if op == "approve":
|
||||
node.user_approved = True
|
||||
|
||||
elif op == "reject":
|
||||
node.user_approved = False
|
||||
|
||||
elif op == "edit":
|
||||
if not data.content:
|
||||
raise HTTPException(status_code=400, detail="Content required for edit operation.")
|
||||
node.content = data.content
|
||||
node.user_edited = True
|
||||
|
||||
elif op == "delete":
|
||||
await db.delete(node)
|
||||
# Reorder remaining nodes
|
||||
remaining = await db.execute(
|
||||
select(KBImportNode)
|
||||
.where(KBImportNode.kb_import_id == import_id)
|
||||
.order_by(KBImportNode.node_order)
|
||||
)
|
||||
for idx, n in enumerate(remaining.scalars().all()):
|
||||
n.node_order = idx
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
# Return a placeholder response for deleted node
|
||||
return KBImportNodeResponse(
|
||||
id=node_id,
|
||||
kb_import_id=import_id,
|
||||
node_order=-1,
|
||||
node_type="step",
|
||||
content={"deleted": True},
|
||||
confidence_score=0,
|
||||
user_edited=False,
|
||||
user_approved=False,
|
||||
)
|
||||
|
||||
elif op == "insert_after":
|
||||
if not data.content:
|
||||
raise HTTPException(status_code=400, detail="Content required for insert_after operation.")
|
||||
# Shift subsequent nodes
|
||||
subsequent = await db.execute(
|
||||
select(KBImportNode)
|
||||
.where(
|
||||
KBImportNode.kb_import_id == import_id,
|
||||
KBImportNode.node_order > node.node_order,
|
||||
)
|
||||
.order_by(KBImportNode.node_order)
|
||||
)
|
||||
for n in subsequent.scalars().all():
|
||||
n.node_order += 1
|
||||
|
||||
new_node = KBImportNode(
|
||||
kb_import_id=import_id,
|
||||
node_order=node.node_order + 1,
|
||||
node_type=data.content.get("type", "step"),
|
||||
content=data.content,
|
||||
confidence_score=1.0, # User-created nodes are fully trusted
|
||||
user_edited=True,
|
||||
user_approved=True,
|
||||
)
|
||||
db.add(new_node)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
return KBImportNodeResponse.model_validate(new_node)
|
||||
|
||||
elif op == "regenerate":
|
||||
# Re-run AI for just this node (simplified: update placeholder)
|
||||
# Full implementation would call AI with node context + guidance
|
||||
node.user_edited = False
|
||||
node.user_approved = False
|
||||
|
||||
node.updated_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
return KBImportNodeResponse.model_validate(node)
|
||||
|
||||
|
||||
@router.post("/{import_id}/commit", response_model=KBCommitResponse)
|
||||
async def commit_import(
|
||||
import_id: UUID,
|
||||
user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
data: Optional[KBCommitRequest] = None,
|
||||
):
|
||||
"""Commit a reviewed KB import to the flow library as a Tree."""
|
||||
kb_import = await _get_import_or_404(import_id, user, db)
|
||||
|
||||
if kb_import.status != "ready":
|
||||
raise HTTPException(status_code=400, detail="Import must be in 'ready' status to commit.")
|
||||
|
||||
if not kb_import.nodes:
|
||||
raise HTTPException(status_code=400, detail="No nodes to commit.")
|
||||
|
||||
# Extract title/description from conversion metadata
|
||||
conversion_meta = (kb_import.source_metadata or {}).get("_conversion", {})
|
||||
tree_name = (data.name if data and data.name else None) or conversion_meta.get("title", "Imported Flow")
|
||||
tree_description = (data.description if data else None) or conversion_meta.get("description")
|
||||
|
||||
# Build tree_structure from nodes
|
||||
if kb_import.target_type == "troubleshooting":
|
||||
tree_structure = _build_troubleshooting_tree(kb_import.nodes)
|
||||
else:
|
||||
tree_structure = _build_procedural_tree(kb_import.nodes)
|
||||
|
||||
# Validate the built tree before committing
|
||||
if kb_import.target_type == "troubleshooting":
|
||||
validation_errors = validate_generated_tree(tree_structure)
|
||||
if validation_errors:
|
||||
logger.warning(
|
||||
"KB commit blocked: tree failed validation with %d errors: %s",
|
||||
len(validation_errors), "; ".join(validation_errors[:5]),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={
|
||||
"message": "The converted flow has structural issues that need to be fixed before committing.",
|
||||
"validation_errors": validation_errors,
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Procedural/maintenance validation
|
||||
is_valid, proc_errors = validate_procedural_structure(tree_structure)
|
||||
if not is_valid:
|
||||
error_messages = [e.get("message", str(e)) for e in proc_errors]
|
||||
logger.warning(
|
||||
"KB commit blocked: procedural flow failed validation with %d errors: %s",
|
||||
len(proc_errors), "; ".join(error_messages[:5]),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={
|
||||
"message": "The converted flow has structural issues that need to be fixed before committing.",
|
||||
"validation_errors": error_messages,
|
||||
},
|
||||
)
|
||||
|
||||
# Build intake_form for procedural flows
|
||||
intake_form = None
|
||||
if kb_import.target_type == "procedural":
|
||||
intake_form = (kb_import.source_metadata or {}).get("_intake_form")
|
||||
|
||||
# Create the Tree record
|
||||
tree = Tree(
|
||||
name=tree_name,
|
||||
description=tree_description,
|
||||
tree_type=kb_import.target_type,
|
||||
tree_structure=tree_structure,
|
||||
intake_form=intake_form,
|
||||
author_id=user.id,
|
||||
account_id=user.account_id,
|
||||
status="draft",
|
||||
import_metadata={
|
||||
"source": "kb_accelerator",
|
||||
"kb_import_id": str(kb_import.id),
|
||||
"source_filename": kb_import.source_filename,
|
||||
"source_format": kb_import.source_format,
|
||||
"confidence_avg": kb_import.confidence_avg,
|
||||
"node_count": len(kb_import.nodes),
|
||||
"converted_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
if data and data.category_id:
|
||||
tree.category_id = data.category_id
|
||||
|
||||
db.add(tree)
|
||||
await db.flush()
|
||||
|
||||
kb_import.status = "committed"
|
||||
kb_import.tree_id = tree.id
|
||||
await db.commit()
|
||||
|
||||
return KBCommitResponse(
|
||||
tree_id=tree.id,
|
||||
import_id=kb_import.id,
|
||||
tree_type=kb_import.target_type,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{import_id}", status_code=204)
|
||||
async def delete_import(
|
||||
import_id: UUID,
|
||||
user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Cancel and clean up a KB import."""
|
||||
kb_import = await _get_import_or_404(import_id, user, db, load_nodes=False)
|
||||
if kb_import.status == "committed":
|
||||
raise HTTPException(status_code=400, detail="Cannot delete a committed import.")
|
||||
await db.execute(
|
||||
delete(KBImportNode).where(KBImportNode.kb_import_id == import_id)
|
||||
)
|
||||
await db.delete(kb_import)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Tree Structure Builders ──
|
||||
|
||||
|
||||
def _build_troubleshooting_tree(nodes: list[KBImportNode]) -> dict:
|
||||
"""Build a troubleshooting tree_structure from import nodes.
|
||||
|
||||
The tree editor expects a deeply nested structure where each decision
|
||||
node's `children` array contains all reachable descendant nodes.
|
||||
Action/solution nodes use `title`/`description` (not `question`).
|
||||
|
||||
The AI generates a DAG (shared nodes reachable from multiple paths),
|
||||
but the tree editor requires unique IDs — each node can only appear
|
||||
once. We embed each node the first time it's encountered; subsequent
|
||||
references just use next_node_id / options[].next_node_id to point
|
||||
back to the already-embedded node.
|
||||
"""
|
||||
if not nodes:
|
||||
return {"id": "root", "type": "decision", "question": "Empty", "children": []}
|
||||
|
||||
# Map original IDs to import nodes
|
||||
original_id_map: dict[str, KBImportNode] = {}
|
||||
for node in nodes:
|
||||
orig_id = node.content.get("original_id", str(node.id))
|
||||
original_id_map[orig_id] = node
|
||||
|
||||
# Track which nodes have been placed in the tree to avoid duplicates
|
||||
placed: set[str] = set()
|
||||
|
||||
def _build_node(import_node: KBImportNode) -> dict | None:
|
||||
content = import_node.content
|
||||
node_type = import_node.node_type
|
||||
node_id = content.get("original_id", str(import_node.id))
|
||||
|
||||
# Already placed in the tree — don't create a duplicate.
|
||||
# The reference (next_node_id / options) is sufficient.
|
||||
if node_id in placed:
|
||||
return None
|
||||
placed.add(node_id)
|
||||
|
||||
question_text = content.get("question", "")
|
||||
|
||||
if node_type == "resolution":
|
||||
return {
|
||||
"id": node_id,
|
||||
"type": "solution",
|
||||
"title": question_text,
|
||||
"description": content.get("description", ""),
|
||||
}
|
||||
|
||||
if node_type in ("action", "warning"):
|
||||
result: dict = {
|
||||
"id": node_id,
|
||||
"type": "action",
|
||||
"title": question_text,
|
||||
"description": content.get("description", ""),
|
||||
}
|
||||
next_id = content.get("next_node_id")
|
||||
if next_id and next_id in original_id_map:
|
||||
result["next_node_id"] = next_id
|
||||
return result
|
||||
|
||||
# question/decision type — recursively build children
|
||||
options = content.get("options", [])
|
||||
|
||||
# Count how many options point to buildable (not-yet-placed) targets
|
||||
buildable_targets = []
|
||||
for opt in options:
|
||||
next_id = opt.get("next_node_id")
|
||||
if next_id and next_id in original_id_map and next_id not in placed:
|
||||
buildable_targets.append(next_id)
|
||||
|
||||
# Decision nodes MUST have at least 2 branches to pass validation.
|
||||
# If fewer than 2 buildable targets, demote to action node.
|
||||
if len(buildable_targets) < 2:
|
||||
demoted: dict = {
|
||||
"id": node_id,
|
||||
"type": "action",
|
||||
"title": question_text,
|
||||
"description": content.get("description", ""),
|
||||
}
|
||||
if buildable_targets:
|
||||
demoted["next_node_id"] = buildable_targets[0]
|
||||
elif options:
|
||||
# All targets already placed; reference first option's target
|
||||
first_next = options[0].get("next_node_id")
|
||||
if first_next:
|
||||
demoted["next_node_id"] = first_next
|
||||
return demoted
|
||||
|
||||
# Build children for decision node
|
||||
children = []
|
||||
built_options = []
|
||||
for opt in options:
|
||||
next_id = opt.get("next_node_id")
|
||||
opt_id = opt.get("id", f"opt-{node_id}-{len(built_options)}")
|
||||
if next_id and next_id in original_id_map:
|
||||
child_node = _build_node(original_id_map[next_id])
|
||||
if child_node is not None:
|
||||
children.append(child_node)
|
||||
_collect_action_chain(child_node, children)
|
||||
built_options.append({
|
||||
"id": opt_id,
|
||||
"label": opt.get("label", ""),
|
||||
"next_node_id": next_id,
|
||||
})
|
||||
else:
|
||||
built_options.append({
|
||||
"id": opt_id,
|
||||
"label": opt.get("label", ""),
|
||||
"next_node_id": next_id or "",
|
||||
})
|
||||
|
||||
return {
|
||||
"id": node_id,
|
||||
"type": "decision",
|
||||
"question": question_text,
|
||||
"options": built_options,
|
||||
"children": children,
|
||||
}
|
||||
|
||||
def _collect_action_chain(node: dict, siblings: list[dict]) -> None:
|
||||
"""Follow action node next_node_id chains and add targets as siblings."""
|
||||
if node.get("type") != "action":
|
||||
return
|
||||
next_id = node.get("next_node_id")
|
||||
if not next_id or next_id not in original_id_map:
|
||||
return
|
||||
# Don't add if already in this siblings list or already placed
|
||||
if any(s["id"] == next_id for s in siblings):
|
||||
return
|
||||
target = _build_node(original_id_map[next_id])
|
||||
if target is None:
|
||||
return
|
||||
siblings.append(target)
|
||||
# Continue chain if the target is also an action
|
||||
_collect_action_chain(target, siblings)
|
||||
|
||||
root_node = nodes[0]
|
||||
result = _build_node(root_node)
|
||||
if not result:
|
||||
return {"id": "root", "type": "decision", "question": "Empty", "children": []}
|
||||
|
||||
# Post-build repair: fix structural issues caused by placed-set race conditions
|
||||
_repair_tree(result)
|
||||
|
||||
# Ensure root is a valid decision node (validator requires this)
|
||||
if result.get("type") == "decision":
|
||||
children = result.get("children", [])
|
||||
options = result.get("options", [])
|
||||
# Root must have >= 2 children and >= 2 options
|
||||
if len(children) < 2 or len(options) < 2:
|
||||
logger.warning(
|
||||
"KB tree root has %d children and %d options after repair; "
|
||||
"tree may fail validation",
|
||||
len(children), len(options),
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _repair_tree(node: dict) -> None:
|
||||
"""Walk the built tree and fix structural issues.
|
||||
|
||||
Fixes (applied bottom-up so child repairs happen before parent checks):
|
||||
- Decision nodes with < 2 children → demote to action, hoist children to parent
|
||||
- Decision nodes with < 2 options → rebuild options from children
|
||||
- Action nodes missing next_node_id → convert to solution
|
||||
"""
|
||||
# Repair children first, then handle this node's children list
|
||||
# We process the children list in-place, potentially expanding it
|
||||
# when demoted decisions hoist their children up.
|
||||
i = 0
|
||||
children = node.get("children", [])
|
||||
while i < len(children):
|
||||
child = children[i]
|
||||
if not isinstance(child, dict):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Recurse into child first
|
||||
_repair_tree(child)
|
||||
|
||||
# After recursion, check if this child is a decision that needs demotion
|
||||
if child.get("type") == "decision":
|
||||
child_children = child.get("children", [])
|
||||
if len(child_children) < 2:
|
||||
_demote_decision_to_action(child, children, i)
|
||||
|
||||
i += 1
|
||||
|
||||
# Now fix this node itself
|
||||
node_type = node.get("type")
|
||||
node_id = node.get("id", "unknown")
|
||||
|
||||
if node_type == "decision":
|
||||
children = node.get("children", [])
|
||||
options = node.get("options", [])
|
||||
|
||||
if len(options) < 2 and len(children) >= 2:
|
||||
# Rebuild options from children
|
||||
node["options"] = [
|
||||
{
|
||||
"id": f"opt-{node_id}-{i}",
|
||||
"label": c.get("question") or c.get("title", f"Option {i+1}"),
|
||||
"next_node_id": c.get("id", ""),
|
||||
}
|
||||
for i, c in enumerate(children)
|
||||
]
|
||||
elif not options:
|
||||
node["options"] = []
|
||||
|
||||
elif node_type == "action":
|
||||
if not node.get("next_node_id"):
|
||||
# Action with no next_node_id → convert to solution
|
||||
node["type"] = "solution"
|
||||
if not node.get("title"):
|
||||
node["title"] = node.get("question", "Resolution")
|
||||
if not node.get("description"):
|
||||
node["description"] = ""
|
||||
|
||||
|
||||
def _demote_decision_to_action(node: dict, siblings: list[dict], index: int) -> None:
|
||||
"""Demote a decision node to action and hoist its children as siblings.
|
||||
|
||||
Args:
|
||||
node: The decision node to demote (modified in-place).
|
||||
siblings: The parent's children list (may be expanded).
|
||||
index: Position of this node in siblings.
|
||||
"""
|
||||
child_children = node.get("children", [])
|
||||
question = node.get("question", "")
|
||||
|
||||
# Pick next_node_id from first child
|
||||
next_id = None
|
||||
if child_children:
|
||||
next_id = child_children[0].get("id")
|
||||
else:
|
||||
options = node.get("options", [])
|
||||
if options:
|
||||
next_id = options[0].get("next_node_id")
|
||||
|
||||
# Convert in-place to action
|
||||
node["type"] = "action"
|
||||
node["title"] = question
|
||||
node["description"] = ""
|
||||
if next_id:
|
||||
node["next_node_id"] = next_id
|
||||
node.pop("question", None)
|
||||
node.pop("options", None)
|
||||
|
||||
# Hoist children as siblings after this node
|
||||
if child_children:
|
||||
hoisted = node.pop("children", [])
|
||||
for j, hoisted_child in enumerate(hoisted):
|
||||
siblings.insert(index + 1 + j, hoisted_child)
|
||||
|
||||
|
||||
# Delete the broken _repair_tree and replace with the working version
|
||||
# by removing the first broken attempt
|
||||
def _build_procedural_tree(nodes: list[KBImportNode]) -> dict:
|
||||
"""Build a procedural tree_structure from import nodes.
|
||||
|
||||
Maps AI node types to valid procedural step types:
|
||||
- step/action/warning → procedure_step
|
||||
- section_header → section_header
|
||||
Adds a procedure_end step at the end if missing.
|
||||
Each step requires 'title' (from content text) and 'content' fields.
|
||||
"""
|
||||
# Type mapping from AI output to valid step types
|
||||
TYPE_MAP = {
|
||||
"step": "procedure_step",
|
||||
"action": "procedure_step",
|
||||
"warning": "procedure_step",
|
||||
"question": "procedure_step",
|
||||
"resolution": "procedure_step",
|
||||
"section_header": "section_header",
|
||||
"procedure_step": "procedure_step",
|
||||
"procedure_end": "procedure_end",
|
||||
}
|
||||
|
||||
steps = []
|
||||
for node in sorted(nodes, key=lambda n: n.node_order):
|
||||
content = node.content
|
||||
raw_type = node.node_type
|
||||
step_type = TYPE_MAP.get(raw_type, "procedure_step")
|
||||
|
||||
step_content = content.get("content", "")
|
||||
step_title = content.get("title") or content.get("question") or step_content[:80] or "Step"
|
||||
|
||||
step: dict = {
|
||||
"id": content.get("original_id", str(node.id)),
|
||||
"type": step_type,
|
||||
"title": step_title,
|
||||
"description": step_content,
|
||||
}
|
||||
|
||||
# Preserve content_type if present
|
||||
content_type = content.get("content_type")
|
||||
if content_type:
|
||||
step["content_type"] = content_type
|
||||
|
||||
steps.append(step)
|
||||
|
||||
# Ensure a procedure_end exists at the end
|
||||
has_end = any(s["type"] == "procedure_end" for s in steps)
|
||||
if not has_end and steps:
|
||||
steps.append({
|
||||
"id": "procedure-end",
|
||||
"type": "procedure_end",
|
||||
"title": "Procedure Complete",
|
||||
"description": "All steps have been completed.",
|
||||
})
|
||||
|
||||
return {
|
||||
"id": "root",
|
||||
"type": "procedural",
|
||||
"steps": steps,
|
||||
}
|
||||
@@ -14,6 +14,8 @@ from app.api.endpoints import survey
|
||||
from app.api.endpoints import admin_survey
|
||||
from app.api.endpoints import tree_transfer
|
||||
from app.api.endpoints import ai_suggestions
|
||||
from app.api.endpoints import kb_accelerator
|
||||
from app.api.endpoints import beta_signup
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -52,3 +54,5 @@ api_router.include_router(survey.router)
|
||||
api_router.include_router(admin_survey.router)
|
||||
api_router.include_router(tree_transfer.router)
|
||||
api_router.include_router(ai_suggestions.router)
|
||||
api_router.include_router(kb_accelerator.router)
|
||||
api_router.include_router(beta_signup.router)
|
||||
|
||||
@@ -151,9 +151,9 @@ def validate_generated_tree(tree: dict[str, Any]) -> list[str]:
|
||||
errors.append(
|
||||
f"Tree has only {node_count} nodes. Minimum 5 required for a useful tree."
|
||||
)
|
||||
if node_count > 50:
|
||||
if node_count > 100:
|
||||
errors.append(
|
||||
f"Tree has {node_count} nodes. Maximum 50 allowed."
|
||||
f"Tree has {node_count} nodes. Maximum 100 allowed."
|
||||
)
|
||||
if solution_count < 2:
|
||||
errors.append(
|
||||
|
||||
@@ -98,6 +98,7 @@ class Settings(BaseSettings):
|
||||
"quick_action": "fast",
|
||||
"open_chat": "standard",
|
||||
"variable_inference": "fast",
|
||||
"kb_convert": "standard",
|
||||
}
|
||||
|
||||
def get_model_for_action(self, action_type: str) -> str:
|
||||
|
||||
@@ -418,6 +418,72 @@ class EmailService:
|
||||
logger.exception("Failed to send survey copy email to %s", to_email)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def send_beta_signup_notification(
|
||||
signup_email: str,
|
||||
notify_email: str = "beta@resolutionflow.com",
|
||||
) -> bool:
|
||||
"""Notify beta@resolutionflow.com about a new beta signup. Fire-and-forget."""
|
||||
if not settings.email_enabled:
|
||||
logger.warning("Beta signup email not sent — RESEND_API_KEY not configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
import resend
|
||||
import html as html_mod
|
||||
from datetime import datetime, timezone
|
||||
|
||||
resend.api_key = settings.RESEND_API_KEY
|
||||
|
||||
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
safe_email = html_mod.escape(signup_email)
|
||||
subject = f"[ResolutionFlow Beta] New signup — {safe_email}"
|
||||
|
||||
email_html = f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
|
||||
<body style="margin:0;padding:0;background:#101114;font-family:'Inter',Helvetica,Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#101114;padding:40px 0;">
|
||||
<tr><td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="background:#14161a;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
|
||||
<tr><td style="padding:40px 40px 24px;text-align:center;">
|
||||
<h1 style="margin:0;color:#f8fafc;font-size:24px;font-weight:600;">Resolution<span style="color:#06b6d4;">Flow</span></h1>
|
||||
<p style="margin:8px 0 0;color:#5a6170;font-size:14px;">New Beta Signup</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 24px;">
|
||||
<p style="margin:0;color:#8891a0;font-size:16px;line-height:1.6;">
|
||||
A new user has requested beta access:
|
||||
</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 24px;text-align:center;">
|
||||
<div style="background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.06);border-radius:12px;padding:20px;">
|
||||
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Email</p>
|
||||
<p style="margin:0;color:#22d3ee;font-size:18px;font-weight:600;">{safe_email}</p>
|
||||
</div>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 32px;">
|
||||
<p style="margin:0;color:#5a6170;font-size:12px;text-align:center;">
|
||||
Submitted at {date_str}
|
||||
</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>"""
|
||||
|
||||
resend.Emails.send({
|
||||
"from": settings.FROM_EMAIL,
|
||||
"to": [notify_email],
|
||||
"reply_to": signup_email,
|
||||
"subject": subject,
|
||||
"html": email_html,
|
||||
})
|
||||
logger.info("Beta signup notification sent for %s", signup_email)
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to send beta signup notification for %s", signup_email)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def send_survey_invite_email(
|
||||
to_email: str,
|
||||
|
||||
582
backend/app/core/kb_conversion_service.py
Normal file
582
backend/app/core/kb_conversion_service.py
Normal file
@@ -0,0 +1,582 @@
|
||||
"""KB Accelerator AI conversion service.
|
||||
|
||||
Converts extracted KB article text into ResolutionFlow tree structures
|
||||
using the Anthropic API (via the shared AI provider layer).
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.ai_provider import get_ai_provider
|
||||
from app.core.ai_quota_service import record_ai_usage, get_user_plan
|
||||
from app.core.config import settings
|
||||
from app.models.kb_import import KBImport, KBImportNode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cost estimation (Sonnet pricing)
|
||||
COST_PER_INPUT_TOKEN = 3.0 / 1_000_000
|
||||
COST_PER_OUTPUT_TOKEN = 15.0 / 1_000_000
|
||||
|
||||
|
||||
def _strip_markdown_fences(text: str) -> str:
|
||||
"""Strip markdown code fences if the model wrapped its JSON response."""
|
||||
text = text.strip()
|
||||
match = re.match(r"^```(?:json)?\s*([\s\S]*?)```$", text)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return text
|
||||
|
||||
|
||||
def _try_repair_json(text: str) -> dict | None:
|
||||
"""Attempt to repair common JSON issues from AI responses.
|
||||
|
||||
Handles: trailing commas, unclosed brackets/braces, truncated responses.
|
||||
Returns parsed dict on success, None on failure.
|
||||
"""
|
||||
# Strip trailing commas before closing brackets/braces
|
||||
repaired = re.sub(r",\s*([}\]])", r"\1", text)
|
||||
|
||||
# Try parsing after comma cleanup
|
||||
try:
|
||||
return json.loads(repaired)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Try closing unclosed brackets/braces (truncated response)
|
||||
# Count open vs close brackets
|
||||
open_braces = repaired.count("{") - repaired.count("}")
|
||||
open_brackets = repaired.count("[") - repaired.count("]")
|
||||
|
||||
if open_braces > 0 or open_brackets > 0:
|
||||
# Remove any trailing partial key-value pair or string
|
||||
# Find the last complete value (ends with }, ], ", number, true, false, null)
|
||||
truncated = repaired.rstrip()
|
||||
# Strip trailing partial string or key
|
||||
truncated = re.sub(r',\s*"[^"]*$', "", truncated) # trailing "partial_key
|
||||
truncated = re.sub(r',\s*$', "", truncated) # trailing comma
|
||||
|
||||
# Close remaining brackets/braces
|
||||
truncated += "]" * max(0, open_brackets)
|
||||
truncated += "}" * max(0, open_braces)
|
||||
|
||||
# Re-strip trailing commas that may have appeared
|
||||
truncated = re.sub(r",\s*([}\]])", r"\1", truncated)
|
||||
|
||||
try:
|
||||
return json.loads(truncated)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _estimate_cost(input_tokens: int, output_tokens: int) -> float:
|
||||
return (input_tokens * COST_PER_INPUT_TOKEN) + (output_tokens * COST_PER_OUTPUT_TOKEN)
|
||||
|
||||
|
||||
# ── System Prompts ──
|
||||
|
||||
TROUBLESHOOTING_SYSTEM_PROMPT = """You are an MSP documentation specialist for ResolutionFlow. Your task is to convert a knowledge base article into an interactive troubleshooting decision tree.
|
||||
|
||||
Analyze the article and produce a JSON array of nodes that form a troubleshooting flow. Each node represents either a diagnostic question (decision point) or a resolution (solution).
|
||||
|
||||
## Node Types
|
||||
|
||||
- **question**: A diagnostic question with multiple answer options. Each option leads to another node.
|
||||
- **resolution**: A terminal node with the solution/fix text.
|
||||
- **action**: An instruction step that leads to the next node via next_node_id.
|
||||
- **warning**: A caution or important note.
|
||||
|
||||
## Output Format
|
||||
|
||||
Return a JSON object with this structure:
|
||||
```json
|
||||
{
|
||||
"title": "Flow title derived from the article",
|
||||
"description": "Brief description of what this flow troubleshoots",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "unique-node-id",
|
||||
"type": "question",
|
||||
"question": "What symptom is the user experiencing?",
|
||||
"options": [
|
||||
{"label": "Cannot connect", "next_node_id": "check-network"},
|
||||
{"label": "Slow performance", "next_node_id": "check-resources"}
|
||||
],
|
||||
"confidence": 0.95,
|
||||
"source_excerpt": "The exact text from the article this node was derived from"
|
||||
},
|
||||
{
|
||||
"id": "check-network",
|
||||
"type": "action",
|
||||
"question": "Check the network connection and ping the server",
|
||||
"next_node_id": "network-result",
|
||||
"confidence": 0.88,
|
||||
"source_excerpt": "Step 1: Verify network connectivity..."
|
||||
},
|
||||
{
|
||||
"id": "solution-restart",
|
||||
"type": "resolution",
|
||||
"question": "Restart the service. The issue should now be resolved.",
|
||||
"confidence": 0.92,
|
||||
"source_excerpt": "Restarting the service resolves the connectivity issue."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. Every node MUST have a unique `id` (descriptive kebab-case).
|
||||
2. Every node MUST have a `confidence` score between 0.0 and 1.0.
|
||||
3. Every node MUST have a `source_excerpt` — the exact text from the source article it was derived from.
|
||||
4. The first node is the root of the decision tree.
|
||||
5. All `next_node_id` and option `next_node_id` references must point to existing node IDs.
|
||||
6. Detect implicit branching logic (e.g., "If X, do Y; otherwise Z") and create decision nodes.
|
||||
7. Produce at least 3 nodes. Maximum 100 nodes.
|
||||
8. Use high confidence (0.9+) for directly stated steps, medium (0.7-0.89) for reasonable inferences, low (<0.7) for significant interpretation.
|
||||
9. Return ONLY valid JSON — no markdown fences, no explanation text."""
|
||||
|
||||
PROCEDURAL_SYSTEM_PROMPT = """You are an MSP documentation specialist for ResolutionFlow. Your task is to convert a knowledge base article into a procedural (step-by-step) flow.
|
||||
|
||||
Analyze the article and produce a JSON object with sequential steps and detected variables.
|
||||
|
||||
## Step Types
|
||||
|
||||
- **step**: A regular instruction step.
|
||||
- **section_header**: A section divider/title (no action, just organizational).
|
||||
- **warning**: A caution or important note that should be highlighted.
|
||||
|
||||
## Variable Detection
|
||||
|
||||
Identify values that would change between executions (server names, IPs, usernames, domains, etc.) and replace them with `[VAR:variable_name]` tokens. Also produce an intake_form that captures these variables before execution.
|
||||
|
||||
## Output Format
|
||||
|
||||
Return a JSON object:
|
||||
```json
|
||||
{
|
||||
"title": "Procedure title derived from the article",
|
||||
"description": "Brief description of what this procedure accomplishes",
|
||||
"steps": [
|
||||
{
|
||||
"id": "unique-step-id",
|
||||
"type": "step",
|
||||
"content": "Open Server Manager and navigate to Add Roles on [VAR:server_name]",
|
||||
"confidence": 0.95,
|
||||
"source_excerpt": "Step 1: Open Server Manager on DC01..."
|
||||
},
|
||||
{
|
||||
"id": "warning-dns",
|
||||
"type": "warning",
|
||||
"content": "WARNING: This will restart DNS and cause brief connectivity loss",
|
||||
"confidence": 0.90,
|
||||
"source_excerpt": "Note: Restarting DNS will cause a brief outage"
|
||||
},
|
||||
{
|
||||
"id": "section-verification",
|
||||
"type": "section_header",
|
||||
"content": "Verification Steps",
|
||||
"confidence": 1.0,
|
||||
"source_excerpt": "Verification"
|
||||
}
|
||||
],
|
||||
"intake_form": [
|
||||
{
|
||||
"variable_name": "server_name",
|
||||
"label": "Server Name",
|
||||
"field_type": "text",
|
||||
"required": true,
|
||||
"display_order": 1
|
||||
},
|
||||
{
|
||||
"variable_name": "ip_address",
|
||||
"label": "IP Address",
|
||||
"field_type": "text",
|
||||
"required": true,
|
||||
"display_order": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Variable Type Mapping
|
||||
|
||||
- IP addresses → field_type: "text", variable like `ip_address`
|
||||
- Server/computer names → field_type: "text", variable like `server_name`
|
||||
- Domain names → field_type: "text", variable like `domain_name`
|
||||
- Usernames/email → field_type: "text", variable like `username`
|
||||
- Port numbers → field_type: "number", variable like `port`
|
||||
|
||||
## Rules
|
||||
|
||||
1. Every step MUST have a unique `id` (descriptive kebab-case).
|
||||
2. Every step MUST have a `confidence` score between 0.0 and 1.0.
|
||||
3. Every step MUST have a `source_excerpt` — the exact text from the source article.
|
||||
4. Preserve the original step ordering from the article.
|
||||
5. Detect ALL instance-specific values and replace with `[VAR:name]` tokens.
|
||||
6. Generate an intake_form entry for each unique variable detected.
|
||||
7. Produce at least 2 steps. Maximum 100 steps.
|
||||
8. Use high confidence (0.9+) for directly stated steps, medium (0.7-0.89) for inferences, low (<0.7) for significant interpretation.
|
||||
9. Return ONLY valid JSON — no markdown fences, no explanation text."""
|
||||
|
||||
|
||||
def _build_user_message(
|
||||
source_text: str,
|
||||
source_metadata: dict[str, Any] | None,
|
||||
source_filename: str | None,
|
||||
) -> str:
|
||||
"""Build the user message containing the extracted text and metadata."""
|
||||
parts = []
|
||||
|
||||
if source_filename:
|
||||
parts.append(f"Source file: {source_filename}")
|
||||
|
||||
if source_metadata:
|
||||
headings = source_metadata.get("headings", [])
|
||||
if headings:
|
||||
heading_text = ", ".join(
|
||||
f"H{h['level']}: {h['text']}" for h in headings[:20]
|
||||
)
|
||||
parts.append(f"Detected headings: {heading_text}")
|
||||
|
||||
lists = source_metadata.get("lists", [])
|
||||
if lists:
|
||||
parts.append(f"Detected {len(lists)} list(s) in the document.")
|
||||
|
||||
tables = source_metadata.get("tables", [])
|
||||
if tables:
|
||||
parts.append(f"Detected {len(tables)} table(s) in the document.")
|
||||
|
||||
parts.append(f"\n--- ARTICLE CONTENT ---\n\n{source_text}")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _parse_troubleshooting_response(
|
||||
data: dict[str, Any],
|
||||
kb_import_id: UUID,
|
||||
) -> tuple[list[KBImportNode], str, str | None]:
|
||||
"""Parse AI response into KBImportNode records for troubleshooting flows.
|
||||
|
||||
Returns (nodes, title, description).
|
||||
"""
|
||||
title = data.get("title", "Imported Troubleshooting Flow")
|
||||
description = data.get("description")
|
||||
raw_nodes = data.get("nodes", [])
|
||||
|
||||
if not raw_nodes:
|
||||
raise ValueError("AI returned no nodes")
|
||||
|
||||
# Build parent mapping from the tree structure
|
||||
# First node is root (no parent). For others, trace via options/next_node_id.
|
||||
node_id_to_parent: dict[str, str | None] = {}
|
||||
node_id_to_data: dict[str, dict[str, Any]] = {}
|
||||
for node in raw_nodes:
|
||||
nid = node.get("id", "")
|
||||
node_id_to_data[nid] = node
|
||||
if nid not in node_id_to_parent:
|
||||
node_id_to_parent[nid] = None # default: no parent
|
||||
|
||||
# Trace parent relationships (only set if it won't create a cycle)
|
||||
def _would_cycle(child: str, parent: str) -> bool:
|
||||
"""Check if setting child's parent to parent creates a cycle."""
|
||||
visited: set[str] = set()
|
||||
cur: str | None = parent
|
||||
while cur:
|
||||
if cur == child:
|
||||
return True
|
||||
if cur in visited:
|
||||
break
|
||||
visited.add(cur)
|
||||
cur = node_id_to_parent.get(cur)
|
||||
return False
|
||||
|
||||
for node in raw_nodes:
|
||||
nid = node.get("id", "")
|
||||
# Options point to children
|
||||
for opt in node.get("options", []):
|
||||
child_id = opt.get("next_node_id")
|
||||
if child_id and child_id in node_id_to_data and not _would_cycle(nid, child_id):
|
||||
node_id_to_parent[child_id] = nid
|
||||
# next_node_id points to child
|
||||
next_id = node.get("next_node_id")
|
||||
if next_id and next_id in node_id_to_data and not _would_cycle(nid, next_id):
|
||||
node_id_to_parent[next_id] = nid
|
||||
|
||||
# Create import node records preserving order
|
||||
import uuid as uuid_mod
|
||||
node_id_map: dict[str, uuid_mod.UUID] = {}
|
||||
nodes: list[KBImportNode] = []
|
||||
|
||||
for order, raw_node in enumerate(raw_nodes):
|
||||
node_uuid = uuid_mod.uuid4()
|
||||
nid = raw_node.get("id", f"node-{order}")
|
||||
node_id_map[nid] = node_uuid
|
||||
|
||||
for order, raw_node in enumerate(raw_nodes):
|
||||
nid = raw_node.get("id", f"node-{order}")
|
||||
node_type = raw_node.get("type", "question")
|
||||
if node_type == "decision":
|
||||
node_type = "question"
|
||||
|
||||
parent_str_id = node_id_to_parent.get(nid)
|
||||
parent_uuid = node_id_map.get(parent_str_id) if parent_str_id else None
|
||||
|
||||
# Build content JSONB
|
||||
content: dict[str, Any] = {
|
||||
"original_id": nid,
|
||||
"question": raw_node.get("question", ""),
|
||||
}
|
||||
if raw_node.get("options"):
|
||||
content["options"] = raw_node["options"]
|
||||
if raw_node.get("next_node_id"):
|
||||
content["next_node_id"] = raw_node["next_node_id"]
|
||||
|
||||
import_node = KBImportNode(
|
||||
id=node_id_map[nid],
|
||||
kb_import_id=kb_import_id,
|
||||
node_order=order,
|
||||
node_type=node_type,
|
||||
content=content,
|
||||
parent_node_id=parent_uuid,
|
||||
source_excerpt=raw_node.get("source_excerpt"),
|
||||
confidence_score=float(raw_node.get("confidence", 0.5)),
|
||||
user_edited=False,
|
||||
user_approved=False,
|
||||
)
|
||||
nodes.append(import_node)
|
||||
|
||||
return nodes, title, description
|
||||
|
||||
|
||||
def _parse_procedural_response(
|
||||
data: dict[str, Any],
|
||||
kb_import_id: UUID,
|
||||
) -> tuple[list[KBImportNode], str, str | None, list[dict[str, Any]] | None]:
|
||||
"""Parse AI response into KBImportNode records for procedural flows.
|
||||
|
||||
Returns (nodes, title, description, intake_form).
|
||||
"""
|
||||
title = data.get("title", "Imported Procedure")
|
||||
description = data.get("description")
|
||||
raw_steps = data.get("steps", [])
|
||||
intake_form = data.get("intake_form")
|
||||
|
||||
if not raw_steps:
|
||||
raise ValueError("AI returned no steps")
|
||||
|
||||
import uuid as uuid_mod
|
||||
nodes: list[KBImportNode] = []
|
||||
|
||||
for order, raw_step in enumerate(raw_steps):
|
||||
content: dict[str, Any] = {
|
||||
"original_id": raw_step.get("id", f"step-{order}"),
|
||||
"content": raw_step.get("content", ""),
|
||||
}
|
||||
|
||||
node_type = raw_step.get("type", "step")
|
||||
if node_type not in ("step", "section_header", "warning"):
|
||||
node_type = "step"
|
||||
|
||||
import_node = KBImportNode(
|
||||
id=uuid_mod.uuid4(),
|
||||
kb_import_id=kb_import_id,
|
||||
node_order=order,
|
||||
node_type=node_type,
|
||||
content=content,
|
||||
parent_node_id=None, # Procedural flows are linear
|
||||
source_excerpt=raw_step.get("source_excerpt"),
|
||||
confidence_score=float(raw_step.get("confidence", 0.5)),
|
||||
user_edited=False,
|
||||
user_approved=False,
|
||||
)
|
||||
nodes.append(import_node)
|
||||
|
||||
return nodes, title, description, intake_form
|
||||
|
||||
|
||||
async def convert_document(
|
||||
kb_import: KBImport,
|
||||
db: AsyncSession,
|
||||
) -> list[KBImportNode]:
|
||||
"""Run AI conversion on an extracted KB article.
|
||||
|
||||
Creates KBImportNode records and updates the kb_import status.
|
||||
Returns the created nodes.
|
||||
"""
|
||||
start_time = time.monotonic()
|
||||
|
||||
# Select system prompt based on target type
|
||||
if kb_import.target_type == "troubleshooting":
|
||||
system_prompt = TROUBLESHOOTING_SYSTEM_PROMPT
|
||||
else:
|
||||
system_prompt = PROCEDURAL_SYSTEM_PROMPT
|
||||
|
||||
user_message = _build_user_message(
|
||||
source_text=kb_import.source_text,
|
||||
source_metadata=kb_import.source_metadata,
|
||||
source_filename=kb_import.source_filename,
|
||||
)
|
||||
|
||||
# Get AI provider with model routing
|
||||
model = settings.get_model_for_action("kb_convert")
|
||||
provider = get_ai_provider(model=model)
|
||||
|
||||
try:
|
||||
raw_text, input_tokens, output_tokens = await provider.generate_json(
|
||||
system_prompt=system_prompt,
|
||||
messages=[{"role": "user", "content": user_message}],
|
||||
max_tokens=16384,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("AI conversion failed for kb_import=%s: %s", kb_import.id, e)
|
||||
kb_import.status = "failed"
|
||||
kb_import.error_message = f"AI processing error: {str(e)}"
|
||||
kb_import.processing_time_ms = int((time.monotonic() - start_time) * 1000)
|
||||
await db.flush()
|
||||
|
||||
# Record failed usage
|
||||
plan = await get_user_plan(kb_import.account_id, db)
|
||||
await record_ai_usage(
|
||||
user_id=kb_import.created_by,
|
||||
account_id=kb_import.account_id,
|
||||
conversation_id=None,
|
||||
generation_type="kb_convert",
|
||||
tier=plan,
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
estimated_cost=0.0,
|
||||
succeeded=False,
|
||||
counts_toward_quota=False,
|
||||
error_code="ai_error",
|
||||
extra_data={"kb_import_id": str(kb_import.id)},
|
||||
db=db,
|
||||
)
|
||||
return []
|
||||
|
||||
# Parse JSON response
|
||||
raw_text = _strip_markdown_fences(raw_text)
|
||||
try:
|
||||
data = json.loads(raw_text)
|
||||
except json.JSONDecodeError as e:
|
||||
# Attempt JSON repair before giving up
|
||||
data = _try_repair_json(raw_text)
|
||||
if data is None:
|
||||
logger.error(
|
||||
"KB conversion JSON parse failed for kb_import=%s (%d chars). "
|
||||
"Parse error: %s. Raw response (first 2000 chars): %s",
|
||||
kb_import.id, len(raw_text), e, raw_text[:2000],
|
||||
)
|
||||
kb_import.status = "failed"
|
||||
kb_import.error_message = (
|
||||
"AI response could not be parsed as valid JSON. "
|
||||
"This can happen with very long articles — try again or simplify the article."
|
||||
)
|
||||
kb_import.processing_time_ms = int((time.monotonic() - start_time) * 1000)
|
||||
kb_import.ai_tokens_input = input_tokens
|
||||
kb_import.ai_tokens_output = output_tokens
|
||||
await db.flush()
|
||||
return []
|
||||
else:
|
||||
logger.info(
|
||||
"KB conversion JSON repaired for kb_import=%s (%d chars)",
|
||||
kb_import.id, len(raw_text),
|
||||
)
|
||||
|
||||
# Parse into nodes based on target type
|
||||
try:
|
||||
intake_form = None
|
||||
if kb_import.target_type == "troubleshooting":
|
||||
nodes, title, description = _parse_troubleshooting_response(
|
||||
data, kb_import.id
|
||||
)
|
||||
else:
|
||||
nodes, title, description, intake_form = _parse_procedural_response(
|
||||
data, kb_import.id
|
||||
)
|
||||
except (ValueError, KeyError, TypeError) as e:
|
||||
logger.error("KB node parsing failed for kb_import=%s: %s", kb_import.id, e)
|
||||
kb_import.status = "failed"
|
||||
kb_import.error_message = f"Failed to parse AI response: {e}"
|
||||
kb_import.processing_time_ms = int((time.monotonic() - start_time) * 1000)
|
||||
kb_import.ai_tokens_input = input_tokens
|
||||
kb_import.ai_tokens_output = output_tokens
|
||||
await db.flush()
|
||||
return []
|
||||
|
||||
# Persist nodes — insert roots first to satisfy parent_node_id FK,
|
||||
# then children in subsequent passes until all are inserted.
|
||||
remaining = list(nodes)
|
||||
inserted_ids: set[Any] = set()
|
||||
while remaining:
|
||||
batch = [
|
||||
n for n in remaining
|
||||
if n.parent_node_id is None or n.parent_node_id in inserted_ids
|
||||
]
|
||||
if not batch:
|
||||
# Circular reference or orphan — force insert remaining to surface the real error
|
||||
for n in remaining:
|
||||
db.add(n)
|
||||
break
|
||||
for n in batch:
|
||||
db.add(n)
|
||||
inserted_ids.add(n.id)
|
||||
await db.flush()
|
||||
remaining = [n for n in remaining if n.id not in inserted_ids]
|
||||
|
||||
# Update import record
|
||||
elapsed_ms = int((time.monotonic() - start_time) * 1000)
|
||||
confidence_scores = [n.confidence_score for n in nodes]
|
||||
avg_confidence = sum(confidence_scores) / len(confidence_scores) if confidence_scores else 0.0
|
||||
|
||||
kb_import.status = "ready"
|
||||
kb_import.confidence_avg = avg_confidence
|
||||
kb_import.processing_time_ms = elapsed_ms
|
||||
kb_import.ai_tokens_input = input_tokens
|
||||
kb_import.ai_tokens_output = output_tokens
|
||||
|
||||
# Store parsed metadata for commit phase
|
||||
if not kb_import.source_metadata:
|
||||
kb_import.source_metadata = {}
|
||||
kb_import.source_metadata["_conversion"] = {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"node_count": len(nodes),
|
||||
}
|
||||
if intake_form:
|
||||
kb_import.source_metadata["_intake_form"] = intake_form
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Record successful usage
|
||||
plan = await get_user_plan(kb_import.account_id, db)
|
||||
cost = _estimate_cost(input_tokens, output_tokens)
|
||||
await record_ai_usage(
|
||||
user_id=kb_import.created_by,
|
||||
account_id=kb_import.account_id,
|
||||
conversation_id=None,
|
||||
generation_type="kb_convert",
|
||||
tier=plan,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
estimated_cost=cost,
|
||||
succeeded=True,
|
||||
counts_toward_quota=True,
|
||||
error_code=None,
|
||||
extra_data={"kb_import_id": str(kb_import.id), "node_count": len(nodes)},
|
||||
db=db,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"KB conversion complete: import=%s, nodes=%d, confidence=%.2f, time=%dms, tokens=%d/%d",
|
||||
kb_import.id, len(nodes), avg_confidence, elapsed_ms, input_tokens, output_tokens,
|
||||
)
|
||||
|
||||
return nodes
|
||||
200
backend/app/core/kb_extraction_service.py
Normal file
200
backend/app/core/kb_extraction_service.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""KB Accelerator text extraction service.
|
||||
|
||||
Extracts plain text and structural metadata from uploaded KB articles.
|
||||
Phase 1: txt, paste, docx. Phase 2 will add pdf, html, md.
|
||||
"""
|
||||
import io
|
||||
import logging
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Type alias for extraction handlers
|
||||
ExtractResult = tuple[str, dict[str, Any] | None]
|
||||
ExtractHandler = Callable[[bytes], ExtractResult]
|
||||
|
||||
|
||||
def _extract_txt(content_bytes: bytes) -> ExtractResult:
|
||||
"""Extract from plain text — pass through with no metadata."""
|
||||
text = content_bytes.decode("utf-8", errors="replace")
|
||||
return text.strip(), None
|
||||
|
||||
|
||||
def _extract_paste(content_bytes: bytes) -> ExtractResult:
|
||||
"""Extract from pasted text — identical to txt."""
|
||||
return _extract_txt(content_bytes)
|
||||
|
||||
|
||||
def _extract_docx(content_bytes: bytes) -> ExtractResult:
|
||||
"""Extract text and structural metadata from a DOCX file.
|
||||
|
||||
Preserves heading levels, list structures, table content,
|
||||
and bold/italic emphasis markers.
|
||||
"""
|
||||
try:
|
||||
from docx import Document
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"python-docx is required for DOCX extraction. "
|
||||
"Install it with: pip install python-docx"
|
||||
)
|
||||
|
||||
doc = Document(io.BytesIO(content_bytes))
|
||||
|
||||
text_parts: list[str] = []
|
||||
metadata: dict[str, Any] = {
|
||||
"headings": [],
|
||||
"lists": [],
|
||||
"tables": [],
|
||||
"emphasis": [],
|
||||
}
|
||||
|
||||
list_items: list[dict[str, Any]] = []
|
||||
current_list_type: str | None = None
|
||||
|
||||
for i, para in enumerate(doc.paragraphs):
|
||||
style_name = para.style.name if para.style else ""
|
||||
text = para.text.strip()
|
||||
if not text:
|
||||
# Flush any accumulated list
|
||||
if list_items:
|
||||
metadata["lists"].append({
|
||||
"type": current_list_type or "unordered",
|
||||
"items": list_items,
|
||||
})
|
||||
list_items = []
|
||||
current_list_type = None
|
||||
text_parts.append("")
|
||||
continue
|
||||
|
||||
# Detect headings
|
||||
if style_name.startswith("Heading"):
|
||||
try:
|
||||
level = int(style_name.split()[-1])
|
||||
except (ValueError, IndexError):
|
||||
level = 1
|
||||
metadata["headings"].append({
|
||||
"level": level,
|
||||
"text": text,
|
||||
"paragraph_index": i,
|
||||
})
|
||||
text_parts.append(text)
|
||||
continue
|
||||
|
||||
# Detect list items
|
||||
if style_name.startswith("List"):
|
||||
is_ordered = "Number" in style_name or "Ordered" in style_name
|
||||
list_type = "ordered" if is_ordered else "unordered"
|
||||
if current_list_type is not None and current_list_type != list_type:
|
||||
# Flush previous list
|
||||
metadata["lists"].append({
|
||||
"type": current_list_type,
|
||||
"items": list_items,
|
||||
})
|
||||
list_items = []
|
||||
current_list_type = list_type
|
||||
list_items.append({"text": text, "paragraph_index": i})
|
||||
text_parts.append(text)
|
||||
continue
|
||||
|
||||
# Flush any accumulated list before a non-list paragraph
|
||||
if list_items:
|
||||
metadata["lists"].append({
|
||||
"type": current_list_type or "unordered",
|
||||
"items": list_items,
|
||||
})
|
||||
list_items = []
|
||||
current_list_type = None
|
||||
|
||||
# Detect emphasis (bold/italic runs)
|
||||
for run in para.runs:
|
||||
run_text = run.text.strip()
|
||||
if not run_text:
|
||||
continue
|
||||
if run.bold:
|
||||
metadata["emphasis"].append({
|
||||
"type": "bold",
|
||||
"text": run_text,
|
||||
"paragraph_index": i,
|
||||
})
|
||||
if run.italic:
|
||||
metadata["emphasis"].append({
|
||||
"type": "italic",
|
||||
"text": run_text,
|
||||
"paragraph_index": i,
|
||||
})
|
||||
|
||||
text_parts.append(text)
|
||||
|
||||
# Flush trailing list
|
||||
if list_items:
|
||||
metadata["lists"].append({
|
||||
"type": current_list_type or "unordered",
|
||||
"items": list_items,
|
||||
})
|
||||
|
||||
# Extract tables
|
||||
for t_idx, table in enumerate(doc.tables):
|
||||
table_data: list[list[str]] = []
|
||||
for row in table.rows:
|
||||
table_data.append([cell.text.strip() for cell in row.cells])
|
||||
if table_data:
|
||||
metadata["tables"].append({
|
||||
"table_index": t_idx,
|
||||
"rows": table_data,
|
||||
})
|
||||
# Also add table content to text
|
||||
for row in table_data:
|
||||
text_parts.append(" | ".join(row))
|
||||
|
||||
full_text = "\n".join(text_parts).strip()
|
||||
|
||||
# Clean up empty metadata sections
|
||||
metadata = {k: v for k, v in metadata.items() if v}
|
||||
|
||||
return full_text, metadata if metadata else None
|
||||
|
||||
|
||||
# Registry of format handlers — extend for Phase 2
|
||||
FORMAT_HANDLERS: dict[str, ExtractHandler] = {
|
||||
"txt": _extract_txt,
|
||||
"md": _extract_txt,
|
||||
"paste": _extract_paste,
|
||||
"docx": _extract_docx,
|
||||
}
|
||||
|
||||
|
||||
def extract_text(
|
||||
content_bytes: bytes,
|
||||
source_format: str,
|
||||
) -> ExtractResult:
|
||||
"""Extract plain text and structural metadata from uploaded content.
|
||||
|
||||
Args:
|
||||
content_bytes: Raw bytes of the uploaded content.
|
||||
source_format: Format identifier ('txt', 'paste', 'docx', etc.)
|
||||
|
||||
Returns:
|
||||
Tuple of (plain_text, structural_metadata_or_none).
|
||||
|
||||
Raises:
|
||||
ValueError: If the format is not supported.
|
||||
RuntimeError: If a required extraction library is not installed.
|
||||
"""
|
||||
handler = FORMAT_HANDLERS.get(source_format)
|
||||
if handler is None:
|
||||
raise ValueError(f"Unsupported format: {source_format}")
|
||||
|
||||
logger.info("Extracting text from format=%s", source_format)
|
||||
text, metadata = handler(content_bytes)
|
||||
|
||||
if not text.strip():
|
||||
raise ValueError("Extracted text is empty — the document may be blank or contain only images.")
|
||||
|
||||
logger.info(
|
||||
"Extraction complete: %d chars, metadata=%s",
|
||||
len(text),
|
||||
"yes" if metadata else "no",
|
||||
)
|
||||
return text, metadata
|
||||
@@ -162,7 +162,7 @@ async def sync_steps_from_tree(
|
||||
is_active = true
|
||||
"""),
|
||||
{
|
||||
"title": step_data["title"],
|
||||
"title": step_data["title"][:255],
|
||||
"step_type": step_data["step_type"],
|
||||
"content": json.dumps(step_data["content"]),
|
||||
"created_by": str(resolved_author_id),
|
||||
|
||||
@@ -34,6 +34,7 @@ from .copilot_conversation import CopilotConversation
|
||||
from .assistant_chat import AssistantChat
|
||||
from .survey_response import SurveyResponse
|
||||
from .survey_invite import SurveyInvite
|
||||
from .kb_import import KBImport, KBImportNode
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -79,4 +80,6 @@ __all__ = [
|
||||
"AssistantChat",
|
||||
"SurveyResponse",
|
||||
"SurveyInvite",
|
||||
"KBImport",
|
||||
"KBImportNode",
|
||||
]
|
||||
|
||||
140
backend/app/models/kb_import.py
Normal file
140
backend/app/models/kb_import.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Any, TYPE_CHECKING
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Float, CheckConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.account import Account
|
||||
from app.models.user import User
|
||||
from app.models.tree import Tree
|
||||
|
||||
|
||||
class KBImport(Base):
|
||||
__tablename__ = "kb_imports"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"source_format IN ('txt', 'paste', 'docx', 'pdf', 'html', 'md')",
|
||||
name="ck_kb_imports_source_format",
|
||||
),
|
||||
CheckConstraint(
|
||||
"target_type IN ('troubleshooting', 'procedural')",
|
||||
name="ck_kb_imports_target_type",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('processing', 'ready', 'committed', 'failed')",
|
||||
name="ck_kb_imports_status",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
source_filename: Mapped[Optional[str]] = mapped_column(
|
||||
String(500), nullable=True
|
||||
)
|
||||
source_format: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
source_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
source_metadata: Mapped[Optional[dict[str, Any]]] = mapped_column(
|
||||
JSONB, nullable=True
|
||||
)
|
||||
target_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="processing"
|
||||
)
|
||||
confidence_avg: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
processing_time_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
ai_tokens_input: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
ai_tokens_output: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
tree_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("trees.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
batch_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), nullable=True, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
account: Mapped["Account"] = relationship("Account", foreign_keys=[account_id])
|
||||
created_by_user: Mapped["User"] = relationship("User", foreign_keys=[created_by])
|
||||
tree: Mapped[Optional["Tree"]] = relationship("Tree", foreign_keys=[tree_id])
|
||||
nodes: Mapped[list["KBImportNode"]] = relationship(
|
||||
"KBImportNode",
|
||||
back_populates="kb_import",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="KBImportNode.node_order",
|
||||
)
|
||||
|
||||
|
||||
class KBImportNode(Base):
|
||||
__tablename__ = "kb_import_nodes"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"node_type IN ('question', 'resolution', 'step', 'section_header', 'warning', 'action')",
|
||||
name="ck_kb_import_nodes_node_type",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
kb_import_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("kb_imports.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
node_order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
node_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
content: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
parent_node_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("kb_import_nodes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
source_excerpt: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
confidence_score: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
user_edited: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
user_approved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
kb_import: Mapped["KBImport"] = relationship(
|
||||
"KBImport", back_populates="nodes"
|
||||
)
|
||||
parent: Mapped[Optional["KBImportNode"]] = relationship(
|
||||
"KBImportNode",
|
||||
remote_side="KBImportNode.id",
|
||||
foreign_keys=[parent_node_id],
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import String, Integer, Boolean
|
||||
from sqlalchemy import String, Integer, Boolean, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from app.core.database import Base
|
||||
@@ -18,3 +18,13 @@ class PlanLimits(Base):
|
||||
# AI Flow Builder limits
|
||||
max_ai_builds_per_month: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
max_ai_builds_per_24h: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# KB Accelerator limits
|
||||
kb_accelerator_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("false"))
|
||||
kb_max_lifetime_conversions: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
kb_batch_max_size: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
kb_allowed_formats: Mapped[list] = mapped_column(JSONB, nullable=False, default=lambda: ["txt", "paste", "md"], server_default=text("'[\"txt\",\"paste\",\"md\"]'::jsonb"))
|
||||
kb_detailed_analysis: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("false"))
|
||||
kb_conversational_refinement: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("false"))
|
||||
kb_step_library_matching: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("false"))
|
||||
kb_history_limit: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
142
backend/app/schemas/kb_accelerator.py
Normal file
142
backend/app/schemas/kb_accelerator.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Pydantic schemas for KB Accelerator."""
|
||||
from typing import Any, Literal, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── Requests ──
|
||||
|
||||
|
||||
class KBUploadTextRequest(BaseModel):
|
||||
"""Upload KB article via text paste."""
|
||||
|
||||
content: str = Field(..., min_length=10, max_length=500_000)
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
target_type: Optional[Literal["troubleshooting", "procedural"]] = Field(
|
||||
None, description="Target flow type. If omitted, AI decides."
|
||||
)
|
||||
|
||||
|
||||
class KBNodeEditRequest(BaseModel):
|
||||
"""Edit a specific KB import node during review."""
|
||||
|
||||
operation: Literal[
|
||||
"approve", "reject", "edit", "delete", "regenerate", "insert_after"
|
||||
]
|
||||
content: Optional[dict[str, Any]] = Field(
|
||||
None, description="Updated node content (required for 'edit' and 'insert_after')"
|
||||
)
|
||||
guidance: Optional[str] = Field(
|
||||
None,
|
||||
max_length=2000,
|
||||
description="User guidance for 'regenerate' operation",
|
||||
)
|
||||
|
||||
|
||||
class KBCommitRequest(BaseModel):
|
||||
"""Optional overrides when committing a KB import to the flow library."""
|
||||
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = Field(None, max_length=2000)
|
||||
category_id: Optional[UUID] = None
|
||||
|
||||
|
||||
# ── Responses ──
|
||||
|
||||
|
||||
class KBImportNodeResponse(BaseModel):
|
||||
"""A single generated node in a KB import."""
|
||||
|
||||
id: UUID
|
||||
kb_import_id: UUID
|
||||
node_order: int
|
||||
node_type: str
|
||||
content: dict[str, Any]
|
||||
parent_node_id: Optional[UUID] = None
|
||||
source_excerpt: Optional[str] = None
|
||||
confidence_score: float
|
||||
user_edited: bool
|
||||
user_approved: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class KBUploadResponse(BaseModel):
|
||||
"""Response after uploading a KB article."""
|
||||
|
||||
id: UUID
|
||||
status: str
|
||||
source_format: str
|
||||
|
||||
|
||||
class KBImportResponse(BaseModel):
|
||||
"""Full KB import detail with nodes."""
|
||||
|
||||
id: UUID
|
||||
account_id: UUID
|
||||
created_by: UUID
|
||||
source_filename: Optional[str] = None
|
||||
source_format: str
|
||||
source_text: str
|
||||
source_metadata: Optional[dict[str, Any]] = None
|
||||
target_type: str
|
||||
status: str
|
||||
confidence_avg: Optional[float] = None
|
||||
error_message: Optional[str] = None
|
||||
processing_time_ms: Optional[int] = None
|
||||
ai_tokens_input: Optional[int] = None
|
||||
ai_tokens_output: Optional[int] = None
|
||||
tree_id: Optional[UUID] = None
|
||||
nodes: list[KBImportNodeResponse] = []
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class KBImportSummary(BaseModel):
|
||||
"""Lightweight import item for list view."""
|
||||
|
||||
id: UUID
|
||||
source_filename: Optional[str] = None
|
||||
source_format: str
|
||||
target_type: str
|
||||
status: str
|
||||
confidence_avg: Optional[float] = None
|
||||
node_count: int = 0
|
||||
created_at: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class KBImportListResponse(BaseModel):
|
||||
"""Paginated list of KB imports."""
|
||||
|
||||
items: list[KBImportSummary]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
class KBCommitResponse(BaseModel):
|
||||
"""Response after committing a KB import to the flow library."""
|
||||
|
||||
tree_id: UUID
|
||||
import_id: UUID
|
||||
tree_type: str
|
||||
|
||||
|
||||
class KBQuotaResponse(BaseModel):
|
||||
"""Current KB Accelerator entitlements and usage for the user's account."""
|
||||
|
||||
plan: str
|
||||
kb_accelerator_enabled: bool
|
||||
lifetime_conversions_used: int
|
||||
lifetime_conversions_limit: Optional[int] = None
|
||||
allowed_formats: list[str]
|
||||
detailed_analysis: bool
|
||||
conversational_refinement: bool
|
||||
step_library_matching: bool
|
||||
history_limit: Optional[int] = None
|
||||
can_convert: bool
|
||||
@@ -3,7 +3,7 @@ from typing import Optional, Any, Literal
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved"]
|
||||
SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved", "cancelled", "resolved_externally"]
|
||||
|
||||
|
||||
class CustomStepSchema(BaseModel):
|
||||
|
||||
@@ -51,7 +51,15 @@ def _build_flow_context(tree: Tree, current_node_id: Optional[str]) -> str:
|
||||
node = _find_node(tree.tree_structure, current_node_id)
|
||||
if node:
|
||||
parts.append(f"Current node type: {node.get('type', 'unknown')}")
|
||||
parts.append(f"Current node: {node.get('content', node.get('label', 'Unknown'))}")
|
||||
node_label = (
|
||||
node.get('title')
|
||||
or node.get('question')
|
||||
or node.get('description')
|
||||
or node.get('content')
|
||||
or node.get('label')
|
||||
or 'Unknown'
|
||||
)
|
||||
parts.append(f"Current node: {node_label}")
|
||||
# Add options if it's a question/decision node
|
||||
children = node.get("children", [])
|
||||
if children and isinstance(children, list):
|
||||
|
||||
362
backend/tests/test_kb_accelerator.py
Normal file
362
backend/tests/test_kb_accelerator.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""Integration tests for KB Accelerator endpoints."""
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch, PropertyMock
|
||||
from httpx import AsyncClient
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# ── Fixtures ──
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def kb_setup(client, auth_headers, test_db):
|
||||
"""Seed KB plan limits and return helpers."""
|
||||
# Update plan_limits with KB columns for 'free' plan
|
||||
await test_db.execute(
|
||||
__import__("sqlalchemy").text("""
|
||||
UPDATE plan_limits SET
|
||||
kb_accelerator_enabled = true,
|
||||
kb_max_lifetime_conversions = 3,
|
||||
kb_allowed_formats = '["txt","paste"]'::jsonb,
|
||||
kb_detailed_analysis = false,
|
||||
kb_conversational_refinement = false,
|
||||
kb_step_library_matching = false,
|
||||
kb_history_limit = 3
|
||||
WHERE plan = 'free'
|
||||
""")
|
||||
)
|
||||
await test_db.execute(
|
||||
__import__("sqlalchemy").text("""
|
||||
UPDATE plan_limits SET
|
||||
kb_accelerator_enabled = true,
|
||||
kb_max_lifetime_conversions = NULL,
|
||||
kb_allowed_formats = '["txt","paste","docx","pdf","html","md"]'::jsonb,
|
||||
kb_detailed_analysis = true,
|
||||
kb_conversational_refinement = true,
|
||||
kb_step_library_matching = true,
|
||||
kb_history_limit = NULL
|
||||
WHERE plan = 'pro'
|
||||
""")
|
||||
)
|
||||
await test_db.commit()
|
||||
return {"client": client, "headers": auth_headers}
|
||||
|
||||
|
||||
def _mock_ai_enabled():
|
||||
"""Context manager to mock AI as enabled."""
|
||||
return patch.object(
|
||||
type(__import__("app.core.config", fromlist=["settings"]).settings),
|
||||
"ai_enabled",
|
||||
new_callable=PropertyMock,
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
|
||||
SAMPLE_KB_TEXT = """
|
||||
Troubleshooting Outlook Connectivity Issues
|
||||
|
||||
Problem: Users report that Outlook keeps disconnecting from Exchange.
|
||||
|
||||
Step 1: Check Network Connectivity
|
||||
Ping the Exchange server to verify network connectivity.
|
||||
If ping fails, check the network configuration.
|
||||
|
||||
Step 2: Verify Outlook Profile
|
||||
If the network is working, check the Outlook profile settings.
|
||||
Go to Control Panel > Mail > Show Profiles.
|
||||
|
||||
Step 3: Check Exchange Server
|
||||
If the profile is correct, verify the Exchange server is running.
|
||||
Open Services.msc and check Microsoft Exchange services.
|
||||
|
||||
Resolution: After following these steps, Outlook should maintain
|
||||
a persistent connection to Exchange.
|
||||
"""
|
||||
|
||||
MOCK_AI_TROUBLESHOOTING_RESPONSE = json.dumps({
|
||||
"title": "Troubleshooting Outlook Connectivity",
|
||||
"description": "Diagnose and fix Outlook disconnection from Exchange",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "root-check",
|
||||
"type": "question",
|
||||
"question": "Is the network connection working?",
|
||||
"options": [
|
||||
{"label": "Yes", "next_node_id": "check-profile"},
|
||||
{"label": "No", "next_node_id": "fix-network"},
|
||||
],
|
||||
"confidence": 0.92,
|
||||
"source_excerpt": "Step 1: Check Network Connectivity",
|
||||
},
|
||||
{
|
||||
"id": "fix-network",
|
||||
"type": "resolution",
|
||||
"question": "Fix the network configuration and retry.",
|
||||
"confidence": 0.85,
|
||||
"source_excerpt": "If ping fails, check the network configuration.",
|
||||
},
|
||||
{
|
||||
"id": "check-profile",
|
||||
"type": "question",
|
||||
"question": "Is the Outlook profile configured correctly?",
|
||||
"options": [
|
||||
{"label": "Yes", "next_node_id": "check-exchange"},
|
||||
{"label": "No", "next_node_id": "fix-profile"},
|
||||
],
|
||||
"confidence": 0.88,
|
||||
"source_excerpt": "Step 2: Verify Outlook Profile",
|
||||
},
|
||||
{
|
||||
"id": "fix-profile",
|
||||
"type": "resolution",
|
||||
"question": "Reconfigure the Outlook profile via Control Panel > Mail.",
|
||||
"confidence": 0.90,
|
||||
"source_excerpt": "Go to Control Panel > Mail > Show Profiles.",
|
||||
},
|
||||
{
|
||||
"id": "check-exchange",
|
||||
"type": "resolution",
|
||||
"question": "Verify Exchange services are running in Services.msc.",
|
||||
"confidence": 0.87,
|
||||
"source_excerpt": "Open Services.msc and check Microsoft Exchange services.",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
MOCK_AI_PROCEDURAL_RESPONSE = json.dumps({
|
||||
"title": "Setup New Domain Controller",
|
||||
"description": "Step-by-step procedure for setting up a new DC",
|
||||
"steps": [
|
||||
{
|
||||
"id": "step-1",
|
||||
"type": "step",
|
||||
"content": "Open Server Manager on [VAR:server_name]",
|
||||
"confidence": 0.95,
|
||||
"source_excerpt": "Step 1: Open Server Manager on DC01",
|
||||
},
|
||||
{
|
||||
"id": "warning-dns",
|
||||
"type": "warning",
|
||||
"content": "WARNING: This will restart DNS and cause brief connectivity loss",
|
||||
"confidence": 0.90,
|
||||
"source_excerpt": "Note: Restarting DNS will cause a brief outage",
|
||||
},
|
||||
{
|
||||
"id": "step-2",
|
||||
"type": "step",
|
||||
"content": "Configure IP address [VAR:ip_address] on the network adapter",
|
||||
"confidence": 0.88,
|
||||
"source_excerpt": "Configure IP 192.168.1.10 on the adapter",
|
||||
},
|
||||
],
|
||||
"intake_form": [
|
||||
{
|
||||
"variable_name": "server_name",
|
||||
"label": "Server Name",
|
||||
"field_type": "text",
|
||||
"required": True,
|
||||
"display_order": 1,
|
||||
},
|
||||
{
|
||||
"variable_name": "ip_address",
|
||||
"label": "IP Address",
|
||||
"field_type": "text",
|
||||
"required": True,
|
||||
"display_order": 2,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
# ── Upload Tests ──
|
||||
|
||||
|
||||
class TestUpload:
|
||||
async def test_upload_text_paste(self, kb_setup):
|
||||
"""Upload via text paste creates a kb_import in processing status."""
|
||||
c, h = kb_setup["client"], kb_setup["headers"]
|
||||
|
||||
with _mock_ai_enabled():
|
||||
# Mock the background conversion (don't actually call AI)
|
||||
with patch("app.api.endpoints.kb_accelerator._run_conversion"):
|
||||
resp = await c.post(
|
||||
"/api/v1/kb-accelerator/upload",
|
||||
data={"content": SAMPLE_KB_TEXT, "target_type": "troubleshooting"},
|
||||
headers=h,
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["status"] == "processing"
|
||||
assert data["source_format"] == "paste"
|
||||
assert "id" in data
|
||||
|
||||
async def test_upload_empty_content_rejected(self, kb_setup):
|
||||
c, h = kb_setup["client"], kb_setup["headers"]
|
||||
with _mock_ai_enabled():
|
||||
resp = await c.post(
|
||||
"/api/v1/kb-accelerator/upload",
|
||||
data={"content": "short"},
|
||||
headers=h,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_upload_no_file_no_content_rejected(self, kb_setup):
|
||||
c, h = kb_setup["client"], kb_setup["headers"]
|
||||
with _mock_ai_enabled():
|
||||
resp = await c.post(
|
||||
"/api/v1/kb-accelerator/upload",
|
||||
data={},
|
||||
headers=h,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ── Get/List Tests ──
|
||||
|
||||
|
||||
class TestGetList:
|
||||
async def test_get_import(self, kb_setup):
|
||||
c, h = kb_setup["client"], kb_setup["headers"]
|
||||
|
||||
with _mock_ai_enabled(), patch("app.api.endpoints.kb_accelerator._run_conversion"):
|
||||
create_resp = await c.post(
|
||||
"/api/v1/kb-accelerator/upload",
|
||||
data={"content": SAMPLE_KB_TEXT, "target_type": "troubleshooting"},
|
||||
headers=h,
|
||||
)
|
||||
import_id = create_resp.json()["id"]
|
||||
|
||||
resp = await c.get(f"/api/v1/kb-accelerator/{import_id}", headers=h)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == import_id
|
||||
assert data["source_format"] == "paste"
|
||||
|
||||
async def test_list_imports(self, kb_setup):
|
||||
c, h = kb_setup["client"], kb_setup["headers"]
|
||||
|
||||
with _mock_ai_enabled(), patch("app.api.endpoints.kb_accelerator._run_conversion"):
|
||||
await c.post(
|
||||
"/api/v1/kb-accelerator/upload",
|
||||
data={"content": SAMPLE_KB_TEXT, "target_type": "troubleshooting"},
|
||||
headers=h,
|
||||
)
|
||||
|
||||
resp = await c.get("/api/v1/kb-accelerator", headers=h)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 1
|
||||
assert len(data["items"]) >= 1
|
||||
|
||||
|
||||
# ── Quota Tests ──
|
||||
|
||||
|
||||
class TestQuota:
|
||||
async def test_get_quota(self, kb_setup):
|
||||
c, h = kb_setup["client"], kb_setup["headers"]
|
||||
resp = await c.get("/api/v1/kb-accelerator/quota", headers=h)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["kb_accelerator_enabled"] is True
|
||||
assert data["lifetime_conversions_limit"] == 3
|
||||
assert data["can_convert"] is True
|
||||
|
||||
|
||||
# ── Commit Tests ──
|
||||
|
||||
|
||||
class TestCommit:
|
||||
async def test_commit_creates_tree(self, kb_setup, test_db):
|
||||
"""Committing a ready import creates a Tree record."""
|
||||
c, h = kb_setup["client"], kb_setup["headers"]
|
||||
|
||||
# Create import
|
||||
with _mock_ai_enabled(), patch("app.api.endpoints.kb_accelerator._run_conversion"):
|
||||
create_resp = await c.post(
|
||||
"/api/v1/kb-accelerator/upload",
|
||||
data={"content": SAMPLE_KB_TEXT, "target_type": "troubleshooting"},
|
||||
headers=h,
|
||||
)
|
||||
import_id = create_resp.json()["id"]
|
||||
|
||||
# Simulate conversion complete: update status + add nodes directly
|
||||
from app.models.kb_import import KBImport, KBImportNode
|
||||
from sqlalchemy import select
|
||||
import uuid
|
||||
|
||||
result = await test_db.execute(select(KBImport).where(KBImport.id == uuid.UUID(import_id)))
|
||||
kb_import = result.scalar_one()
|
||||
kb_import.status = "ready"
|
||||
kb_import.source_metadata = {"_conversion": {"title": "Test Flow", "description": "Test"}}
|
||||
|
||||
# Build a valid tree: root decision with 2 branches leading to solutions
|
||||
nodes_data = [
|
||||
KBImportNode(
|
||||
kb_import_id=kb_import.id, node_order=0, node_type="question",
|
||||
content={
|
||||
"original_id": "root", "question": "What is the issue?",
|
||||
"options": [
|
||||
{"id": "opt-root-0", "label": "Option A", "next_node_id": "action-a"},
|
||||
{"id": "opt-root-1", "label": "Option B", "next_node_id": "action-b"},
|
||||
],
|
||||
},
|
||||
confidence_score=0.9,
|
||||
),
|
||||
KBImportNode(
|
||||
kb_import_id=kb_import.id, node_order=1, node_type="action",
|
||||
content={"original_id": "action-a", "question": "Try fix A", "description": "Do thing A", "next_node_id": "solution-a"},
|
||||
confidence_score=0.9,
|
||||
),
|
||||
KBImportNode(
|
||||
kb_import_id=kb_import.id, node_order=2, node_type="action",
|
||||
content={"original_id": "action-b", "question": "Try fix B", "description": "Do thing B", "next_node_id": "solution-b"},
|
||||
confidence_score=0.9,
|
||||
),
|
||||
KBImportNode(
|
||||
kb_import_id=kb_import.id, node_order=3, node_type="resolution",
|
||||
content={"original_id": "solution-a", "question": "Resolved via A", "description": "Issue fixed by A"},
|
||||
confidence_score=0.9,
|
||||
),
|
||||
KBImportNode(
|
||||
kb_import_id=kb_import.id, node_order=4, node_type="resolution",
|
||||
content={"original_id": "solution-b", "question": "Resolved via B", "description": "Issue fixed by B"},
|
||||
confidence_score=0.9,
|
||||
),
|
||||
]
|
||||
for n in nodes_data:
|
||||
test_db.add(n)
|
||||
await test_db.commit()
|
||||
|
||||
# Commit
|
||||
resp = await c.post(f"/api/v1/kb-accelerator/{import_id}/commit", headers=h)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "tree_id" in data
|
||||
assert data["tree_type"] == "troubleshooting"
|
||||
|
||||
|
||||
# ── Delete Tests ──
|
||||
|
||||
|
||||
class TestDelete:
|
||||
async def test_delete_import(self, kb_setup):
|
||||
c, h = kb_setup["client"], kb_setup["headers"]
|
||||
|
||||
with _mock_ai_enabled(), patch("app.api.endpoints.kb_accelerator._run_conversion"):
|
||||
create_resp = await c.post(
|
||||
"/api/v1/kb-accelerator/upload",
|
||||
data={"content": SAMPLE_KB_TEXT, "target_type": "troubleshooting"},
|
||||
headers=h,
|
||||
)
|
||||
import_id = create_resp.json()["id"]
|
||||
|
||||
resp = await c.delete(f"/api/v1/kb-accelerator/{import_id}", headers=h)
|
||||
assert resp.status_code == 204
|
||||
|
||||
# Verify deleted
|
||||
resp = await c.get(f"/api/v1/kb-accelerator/{import_id}", headers=h)
|
||||
assert resp.status_code == 404
|
||||
@@ -163,6 +163,53 @@ class TestSessions:
|
||||
assert data["outcome"] == "resolved"
|
||||
assert data["outcome_notes"] == "Issue fixed after restarting service"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_session_with_cancelled_outcome(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test completing a session with 'cancelled' outcome."""
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"]},
|
||||
headers=auth_headers
|
||||
)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/complete",
|
||||
json={"outcome": "cancelled", "outcome_notes": "Ticket withdrawn by client"},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["outcome"] == "cancelled"
|
||||
assert data["outcome_notes"] == "Ticket withdrawn by client"
|
||||
assert data["completed_at"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_session_with_resolved_externally_outcome(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test completing a session with 'resolved_externally' outcome."""
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"]},
|
||||
headers=auth_headers
|
||||
)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/complete",
|
||||
json={"outcome": "resolved_externally"},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["outcome"] == "resolved_externally"
|
||||
assert data["completed_at"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_session_requires_outcome(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
|
||||
BIN
docs/ResolutionFlow-Landing-Page.docx
Normal file
BIN
docs/ResolutionFlow-Landing-Page.docx
Normal file
Binary file not shown.
BIN
docs/ResolutionFlow_Script_Generator_Plan.docx
Normal file
BIN
docs/ResolutionFlow_Script_Generator_Plan.docx
Normal file
Binary file not shown.
228
docs/generate_landing_docx.py
Normal file
228
docs/generate_landing_docx.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Generate a .docx version of the ResolutionFlow landing page content."""
|
||||
from docx import Document
|
||||
from docx.shared import Pt, RGBColor
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
import os
|
||||
|
||||
doc = Document()
|
||||
|
||||
style = doc.styles['Normal']
|
||||
style.font.name = 'Calibri'
|
||||
style.font.size = Pt(11)
|
||||
style.font.color.rgb = RGBColor(0x33, 0x33, 0x33)
|
||||
|
||||
for level in range(1, 4):
|
||||
hs = doc.styles[f'Heading {level}']
|
||||
hs.font.color.rgb = RGBColor(0x10, 0x11, 0x14)
|
||||
|
||||
# ── Title ──
|
||||
title = doc.add_heading('ResolutionFlow', level=0)
|
||||
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
subtitle = doc.add_paragraph('From Issue to Resolution, Documented')
|
||||
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
subtitle.runs[0].font.size = Pt(14)
|
||||
subtitle.runs[0].font.color.rgb = RGBColor(0x06, 0xB6, 0xD4)
|
||||
|
||||
doc.add_paragraph('AI-guided decision trees that walk your engineers through troubleshooting and automatically document every step.').alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
doc.add_paragraph('')
|
||||
|
||||
# ── Hero ──
|
||||
doc.add_heading('Stop writing ticket notes. Start generating them.', level=1)
|
||||
doc.add_paragraph(
|
||||
'AI-guided decision trees that walk your engineers through troubleshooting — '
|
||||
'and automatically document every step, ready for your PSA ticket.'
|
||||
)
|
||||
|
||||
# ── Social Proof ──
|
||||
doc.add_paragraph('')
|
||||
p = doc.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = p.add_run('Built by MSP engineers, for MSP engineers')
|
||||
run.bold = True
|
||||
run.font.size = Pt(12)
|
||||
|
||||
stats = doc.add_table(rows=1, cols=3)
|
||||
stats.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
for i, (num, label) in enumerate([
|
||||
('15+', 'Years MSP Experience'),
|
||||
('70%', 'Less Time on Documentation'),
|
||||
('0', 'Ticket Notes Written by Hand'),
|
||||
]):
|
||||
cell = stats.cell(0, i)
|
||||
p = cell.paragraphs[0]
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = p.add_run(num)
|
||||
run.bold = True
|
||||
run.font.size = Pt(16)
|
||||
run.font.color.rgb = RGBColor(0x06, 0xB6, 0xD4)
|
||||
p.add_run('\n' + label).font.size = Pt(9)
|
||||
|
||||
doc.add_paragraph('')
|
||||
|
||||
# ── The Problem ──
|
||||
doc.add_heading('The Problem', level=1)
|
||||
doc.add_paragraph('Documentation is broken. Everyone knows it.')
|
||||
doc.add_paragraph(
|
||||
"Engineers don't want to write it. Managers hate chasing it. "
|
||||
"Clients never see it. The same issues get solved from scratch every time."
|
||||
)
|
||||
|
||||
problems = [
|
||||
('15-25 min lost per ticket',
|
||||
'Engineers spend more time documenting what they did than actually doing it. '
|
||||
'After a complex issue, writing notes is the last thing anyone wants to do.'),
|
||||
('Vague, useless notes',
|
||||
'"Fixed Outlook" tells you nothing. Documentation written under pressure tends '
|
||||
'toward generalities that help nobody the second time around.'),
|
||||
('Knowledge walks out the door',
|
||||
'When a senior engineer leaves, years of tribal knowledge disappear overnight. '
|
||||
'New hires spend months building up what was never captured.'),
|
||||
('Context switching kills speed',
|
||||
'Jumping between the issue, documentation tools, PSA tickets, and knowledge bases '
|
||||
'fragments focus and slows resolution.'),
|
||||
]
|
||||
for title_text, desc in problems:
|
||||
p = doc.add_paragraph()
|
||||
run = p.add_run(title_text)
|
||||
run.bold = True
|
||||
p.add_run(f' -- {desc}')
|
||||
|
||||
doc.add_paragraph('')
|
||||
|
||||
# ── The Answer ──
|
||||
doc.add_heading('The Answer', level=1)
|
||||
p = doc.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = p.add_run('Resolution + Documentation - Time = ResolutionFlow')
|
||||
run.bold = True
|
||||
run.font.size = Pt(14)
|
||||
run.font.color.rgb = RGBColor(0x06, 0xB6, 0xD4)
|
||||
|
||||
doc.add_paragraph(
|
||||
'What if documentation was a byproduct of solving the issue -- not a separate task? '
|
||||
'What if your engineers never had to write another ticket note?'
|
||||
)
|
||||
|
||||
doc.add_paragraph('')
|
||||
|
||||
# ── How It Works ──
|
||||
doc.add_heading('How It Works', level=1)
|
||||
doc.add_paragraph('Three steps. Zero note-writing.')
|
||||
doc.add_paragraph('Build once, run forever. Every session generates documentation automatically.')
|
||||
|
||||
steps = [
|
||||
('1. Build a Flow',
|
||||
'Use the visual Flow Editor to create branching decision trees for any troubleshooting scenario. '
|
||||
'Drag, connect, and enrich steps with commands, notes, and AI suggestions.'),
|
||||
('2. Run a Session',
|
||||
'An engineer launches the flow on a live ticket. FlowPilot -- your AI copilot -- acts as a virtual '
|
||||
'senior engineer, guiding decisions and capturing every action in real time.'),
|
||||
('3. Export to Ticket',
|
||||
'When the session ends, full documentation is generated -- formatted for your PSA. Paste it directly '
|
||||
'into ConnectWise, Atera, or Syncro. Done.'),
|
||||
]
|
||||
for step_title, step_desc in steps:
|
||||
doc.add_heading(step_title, level=2)
|
||||
doc.add_paragraph(step_desc)
|
||||
|
||||
doc.add_paragraph('')
|
||||
|
||||
# ── Features ──
|
||||
doc.add_heading('Features', level=1)
|
||||
doc.add_paragraph('Everything your team needs to resolve faster and document better.')
|
||||
|
||||
features = [
|
||||
('FlowPilot -- Your AI Copilot',
|
||||
'Like having a senior engineer on every call. FlowPilot suggests next steps, provides context-aware '
|
||||
'guidance, and automatically captures documentation as a byproduct of the troubleshooting session.'),
|
||||
('Visual Flow Editor',
|
||||
'Build branching decision trees with a drag-and-drop canvas. Add steps, conditions, commands, and notes -- no code required.'),
|
||||
('Auto-Documentation',
|
||||
'Every session generates timestamped, detailed notes -- formatted for your PSA. Engineers never write another ticket note.'),
|
||||
('Team Knowledge Sharing',
|
||||
'Share flows across your team. When one engineer solves a new problem, the whole team benefits from that path -- instantly.'),
|
||||
('Session History & Analytics',
|
||||
'Track which flows are used most, identify bottlenecks, and see how your team resolves issues over time.'),
|
||||
('PSA Integration',
|
||||
'Connect directly to ConnectWise, Atera, and Syncro. Export session docs straight to tickets -- no copy-paste needed.'),
|
||||
]
|
||||
for feat_title, feat_desc in features:
|
||||
p = doc.add_paragraph()
|
||||
run = p.add_run(feat_title)
|
||||
run.bold = True
|
||||
p.add_run(f' -- {feat_desc}')
|
||||
|
||||
doc.add_paragraph('')
|
||||
|
||||
# ── Pricing ──
|
||||
doc.add_heading('Pricing', level=1)
|
||||
doc.add_paragraph('Simple pricing. No surprises. Start free. Upgrade when your team is ready.')
|
||||
|
||||
pricing_table = doc.add_table(rows=9, cols=4)
|
||||
pricing_table.style = 'Light Grid Accent 1'
|
||||
|
||||
headers = ['', 'Free', 'Pro', 'Team']
|
||||
for i, h in enumerate(headers):
|
||||
cell = pricing_table.cell(0, i)
|
||||
cell.text = h
|
||||
for run in cell.paragraphs[0].runs:
|
||||
run.bold = True
|
||||
|
||||
rows_data = [
|
||||
['Target', 'Individual techs evaluating', 'Small MSPs (1-5 techs)', 'Growing MSPs (5-25 techs)'],
|
||||
['Price', '$0 -- Free forever', '$15/user/mo', '$25/user/mo'],
|
||||
['Decision Trees', '3', 'Unlimited', 'Unlimited'],
|
||||
['Sessions', '20/month', 'Unlimited', 'Unlimited'],
|
||||
['FlowPilot AI', '--', 'Included', 'Included'],
|
||||
['Exports', 'MD, TXT', 'All formats', 'All formats'],
|
||||
['PSA Integration', '--', '--', 'ConnectWise, Atera, Syncro'],
|
||||
['Support', 'Community', 'Priority', 'Dedicated'],
|
||||
]
|
||||
for row_idx, row_data in enumerate(rows_data):
|
||||
for col_idx, val in enumerate(row_data):
|
||||
pricing_table.cell(row_idx + 1, col_idx).text = val
|
||||
|
||||
doc.add_paragraph('')
|
||||
p = doc.add_paragraph('Need Enterprise (25+ techs, SSO, custom branding)? Contact us at ')
|
||||
run = p.add_run('hello@resolutionflow.com')
|
||||
run.font.color.rgb = RGBColor(0x06, 0xB6, 0xD4)
|
||||
|
||||
doc.add_paragraph('')
|
||||
|
||||
# ── Testimonial ──
|
||||
doc.add_heading('What People Are Saying', level=1)
|
||||
p = doc.add_paragraph()
|
||||
run = p.add_run(
|
||||
'"We used to spend more time writing ticket notes than solving the actual issue. '
|
||||
'Now it just... happens. The documentation writes itself while we work."'
|
||||
)
|
||||
run.italic = True
|
||||
run.font.size = Pt(12)
|
||||
doc.add_paragraph('-- Beta Tester, MSP Engineer, Southeast US')
|
||||
|
||||
doc.add_paragraph('')
|
||||
|
||||
# ── CTA ──
|
||||
doc.add_heading('Ready to stop writing ticket notes?', level=1)
|
||||
doc.add_paragraph(
|
||||
'Join the beta and see what happens when documentation becomes automatic.\n'
|
||||
'Free to start. No credit card required.'
|
||||
)
|
||||
p = doc.add_paragraph()
|
||||
run = p.add_run('Sign up at resolutionflow.com')
|
||||
run.bold = True
|
||||
run.font.color.rgb = RGBColor(0x06, 0xB6, 0xD4)
|
||||
|
||||
doc.add_paragraph('')
|
||||
|
||||
# ── Footer ──
|
||||
p = doc.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = p.add_run('\u00a9 2026 ResolutionFlow. All rights reserved.')
|
||||
run.font.size = Pt(9)
|
||||
run.font.color.rgb = RGBColor(0x88, 0x91, 0xA0)
|
||||
|
||||
# Save
|
||||
out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'ResolutionFlow-Landing-Page.docx')
|
||||
doc.save(out_path)
|
||||
print(f'Saved to {out_path}')
|
||||
60
docs/plans/2026-03-11-session-closure-design.md
Normal file
60
docs/plans/2026-03-11-session-closure-design.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Session Closure from History Page — Design
|
||||
|
||||
> **Date:** 2026-03-11
|
||||
|
||||
## Problem
|
||||
|
||||
Active sessions on the Session History page only have "View Details" and "Resume" buttons. Engineers have no way to close out sessions that were abandoned, resolved externally, or otherwise no longer needed — without resuming the entire flow.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **Outcome model:** Hybrid — reuse existing 4 outcomes (resolved, escalated, workaround, unresolved) + add 2 early-closure outcomes (cancelled, resolved_externally)
|
||||
- **UX:** Inline popover anchored to a "Close" button on the session card — no modal, no slide panel
|
||||
- **Scope:** Active sessions only (started but not completed). No bulk close. No AI summary generation.
|
||||
- **Backend:** No new endpoints or migrations. Expand `SessionOutcome` literal type; existing `POST /sessions/{id}/complete` handles everything.
|
||||
|
||||
## Data Model
|
||||
|
||||
No new columns. Expand `SessionOutcome` in `backend/app/schemas/session.py`:
|
||||
|
||||
```python
|
||||
SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved", "cancelled", "resolved_externally"]
|
||||
```
|
||||
|
||||
`VARCHAR(20)` on `session.outcome` fits both new values (max 19 chars for `resolved_externally`).
|
||||
|
||||
## UI
|
||||
|
||||
### Close Button
|
||||
|
||||
Appears on active session cards (`started_at` is set, `completed_at` is null), between "View Details" and "Resume":
|
||||
|
||||
```
|
||||
[View Details] [Close] [Resume]
|
||||
```
|
||||
|
||||
Secondary button styling (border, muted text). Not shown on prepared or completed sessions.
|
||||
|
||||
### Close Popover
|
||||
|
||||
Anchored below the "Close" button:
|
||||
|
||||
- **Outcome selector:** `<select>` with 6 options — Resolved, Escalated, Workaround, Unresolved, Cancelled, Resolved Externally
|
||||
- **Notes:** Optional textarea (2 rows)
|
||||
- **Confirm:** `bg-gradient-brand`, disabled until outcome selected
|
||||
- **Cancel / click outside:** Closes popover
|
||||
- Glass card styling (`glass-card-static` pattern)
|
||||
|
||||
On confirm: calls `POST /sessions/{id}/complete` with `{ outcome, outcome_notes }`, updates local state, shows toast.
|
||||
|
||||
## Implementation Scope
|
||||
|
||||
### Backend (2 files)
|
||||
1. `backend/app/schemas/session.py` — add new outcome values to `SessionOutcome`
|
||||
2. Update frontend outcome type to match
|
||||
|
||||
### Frontend (2-3 files)
|
||||
1. `frontend/src/types/` — update `SessionOutcome` TypeScript type
|
||||
2. `frontend/src/pages/SessionHistoryPage.tsx` — add Close button, popover, outcome label formatting for new values
|
||||
|
||||
No new components, endpoints, or migrations.
|
||||
404
docs/plans/2026-03-11-session-closure.md
Normal file
404
docs/plans/2026-03-11-session-closure.md
Normal file
@@ -0,0 +1,404 @@
|
||||
# Session Closure from History Page — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Allow engineers to close active sessions directly from the Session History page via an inline popover with outcome selection and optional notes.
|
||||
|
||||
**Architecture:** No new endpoints or migrations. Expand the existing `SessionOutcome` type with two new values (`cancelled`, `resolved_externally`). Add a "Close" button + popover to active session cards on the history page. The popover calls the existing `POST /sessions/{id}/complete` endpoint.
|
||||
|
||||
**Tech Stack:** Python/FastAPI (backend schema only), React/TypeScript (frontend UI)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — Expand SessionOutcome Type
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/schemas/session.py:6`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add to `backend/tests/test_sessions.py`, inside the existing `TestSessions` class, after the `test_complete_session` test:
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_session_with_cancelled_outcome(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test completing a session with 'cancelled' outcome."""
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"]},
|
||||
headers=auth_headers
|
||||
)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/complete",
|
||||
json={"outcome": "cancelled", "outcome_notes": "Ticket withdrawn by client"},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["outcome"] == "cancelled"
|
||||
assert data["outcome_notes"] == "Ticket withdrawn by client"
|
||||
assert data["completed_at"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_session_with_resolved_externally_outcome(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test completing a session with 'resolved_externally' outcome."""
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"]},
|
||||
headers=auth_headers
|
||||
)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/complete",
|
||||
json={"outcome": "resolved_externally"},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["outcome"] == "resolved_externally"
|
||||
assert data["completed_at"] is not None
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `docker exec resolutionflow_backend pytest tests/test_sessions.py::TestSessions::test_complete_session_with_cancelled_outcome tests/test_sessions.py::TestSessions::test_complete_session_with_resolved_externally_outcome -v`
|
||||
|
||||
Expected: FAIL — 422 validation error because `cancelled` and `resolved_externally` are not valid `SessionOutcome` values.
|
||||
|
||||
**Step 3: Update SessionOutcome literal**
|
||||
|
||||
In `backend/app/schemas/session.py`, line 6, change:
|
||||
|
||||
```python
|
||||
SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved"]
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved", "cancelled", "resolved_externally"]
|
||||
```
|
||||
|
||||
No other backend changes needed — the `POST /sessions/{id}/complete` endpoint, the `Session` model (`VARCHAR(20)`), and the `SessionComplete` schema all work with the new values automatically.
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `docker exec resolutionflow_backend pytest tests/test_sessions.py::TestSessions::test_complete_session_with_cancelled_outcome tests/test_sessions.py::TestSessions::test_complete_session_with_resolved_externally_outcome -v`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Run full test suite to check for regressions**
|
||||
|
||||
Run: `docker exec resolutionflow_backend pytest tests/test_sessions.py -v`
|
||||
|
||||
Expected: All session tests PASS.
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/schemas/session.py backend/tests/test_sessions.py
|
||||
git commit -m "feat: add cancelled and resolved_externally session outcomes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Frontend — Update SessionOutcome Type
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/types/session.ts:4`
|
||||
|
||||
**Step 1: Update the TypeScript type**
|
||||
|
||||
In `frontend/src/types/session.ts`, line 4, change:
|
||||
|
||||
```typescript
|
||||
export type SessionOutcome = 'resolved' | 'escalated' | 'workaround' | 'unresolved'
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```typescript
|
||||
export type SessionOutcome = 'resolved' | 'escalated' | 'workaround' | 'unresolved' | 'cancelled' | 'resolved_externally'
|
||||
```
|
||||
|
||||
**Step 2: Verify build**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
|
||||
Expected: Clean build, no errors.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/types/session.ts
|
||||
git commit -m "feat: add cancelled and resolved_externally to frontend SessionOutcome type"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Frontend — Add Close Button and Popover to Session History
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/SessionHistoryPage.tsx`
|
||||
|
||||
This is the main UI task. Add a "Close" button to active session cards and an inline popover with outcome selection + notes.
|
||||
|
||||
**Step 1: Add state and handler**
|
||||
|
||||
At the top of the `SessionHistoryPage` component (after existing `useState` calls around line 25), add:
|
||||
|
||||
```tsx
|
||||
const [closingSessionId, setClosingSessionId] = useState<string | null>(null)
|
||||
const [closeOutcome, setCloseOutcome] = useState<SessionOutcome | ''>('')
|
||||
const [closeNotes, setCloseNotes] = useState('')
|
||||
const [closeLoading, setCloseLoading] = useState(false)
|
||||
```
|
||||
|
||||
Add the import for `SessionOutcome` to the existing type import on line 7:
|
||||
|
||||
```typescript
|
||||
import type { Session, TreeListItem, SessionOutcome } from '@/types'
|
||||
```
|
||||
|
||||
Add `useRef` to the React import on line 1:
|
||||
|
||||
```typescript
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
```
|
||||
|
||||
Add the close handler function inside the component, after `handleClearFilters`:
|
||||
|
||||
```tsx
|
||||
const closePopoverRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleCloseSession = useCallback(async () => {
|
||||
if (!closingSessionId || !closeOutcome) return
|
||||
setCloseLoading(true)
|
||||
try {
|
||||
await sessionsApi.complete(closingSessionId, {
|
||||
outcome: closeOutcome,
|
||||
outcome_notes: closeNotes || undefined,
|
||||
})
|
||||
// Update local state — mark session as completed
|
||||
setSessions(prev =>
|
||||
prev.map(s =>
|
||||
s.id === closingSessionId
|
||||
? { ...s, completed_at: new Date().toISOString(), outcome: closeOutcome, outcome_notes: closeNotes || null }
|
||||
: s
|
||||
)
|
||||
)
|
||||
toast.success('Session closed')
|
||||
setClosingSessionId(null)
|
||||
setCloseOutcome('')
|
||||
setCloseNotes('')
|
||||
} catch {
|
||||
toast.error('Failed to close session')
|
||||
} finally {
|
||||
setCloseLoading(false)
|
||||
}
|
||||
}, [closingSessionId, closeOutcome, closeNotes])
|
||||
|
||||
// Close popover on click outside
|
||||
useEffect(() => {
|
||||
if (!closingSessionId) return
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (closePopoverRef.current && !closePopoverRef.current.contains(e.target as Node)) {
|
||||
setClosingSessionId(null)
|
||||
setCloseOutcome('')
|
||||
setCloseNotes('')
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [closingSessionId])
|
||||
```
|
||||
|
||||
**Step 2: Update formatOutcomeLabel**
|
||||
|
||||
In `SessionHistoryPage.tsx`, update the `formatOutcomeLabel` function (around line 158) to handle new outcomes:
|
||||
|
||||
```tsx
|
||||
const formatOutcomeLabel = (outcome: Session['outcome']): string => {
|
||||
if (!outcome) return 'Not set'
|
||||
const labels: Record<string, string> = {
|
||||
resolved: 'Resolved',
|
||||
escalated: 'Escalated',
|
||||
workaround: 'Workaround',
|
||||
unresolved: 'Unresolved',
|
||||
cancelled: 'Cancelled',
|
||||
resolved_externally: 'Resolved Externally',
|
||||
}
|
||||
return labels[outcome] ?? outcome
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Add outcome badge colors for new outcomes**
|
||||
|
||||
In the session card JSX (around line 249-258), update the outcome badge to handle new outcomes. Add these two lines inside the `cn()` call, after the `!session.outcome` line:
|
||||
|
||||
```tsx
|
||||
session.outcome === 'cancelled' && 'bg-zinc-500/20 text-zinc-300',
|
||||
session.outcome === 'resolved_externally' && 'bg-cyan-500/20 text-cyan-300',
|
||||
```
|
||||
|
||||
**Step 4: Add Close button and popover to the Actions div**
|
||||
|
||||
In the session card's Actions div (around line 288-309), replace the entire `{/* Actions */}` block with:
|
||||
|
||||
```tsx
|
||||
{/* Actions */}
|
||||
<div className="relative flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/sessions/${session.id}`)}
|
||||
className={cn(
|
||||
'rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
{!session.completed_at && session.started_at && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setClosingSessionId(closingSessionId === session.id ? null : session.id)
|
||||
setCloseOutcome('')
|
||||
setCloseNotes('')
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground',
|
||||
closingSessionId === session.id && 'bg-accent text-foreground'
|
||||
)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(getSessionResumePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
|
||||
className={cn(
|
||||
'rounded-md bg-gradient-brand px-3 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90'
|
||||
)}
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Close Session Popover */}
|
||||
{closingSessionId === session.id && (
|
||||
<div
|
||||
ref={closePopoverRef}
|
||||
className="absolute right-0 top-full z-20 mt-2 w-72 rounded-xl border border-border bg-card p-4 shadow-xl"
|
||||
>
|
||||
<p className="text-sm font-heading font-medium text-foreground mb-3">Close Session</p>
|
||||
|
||||
<label className="block text-xs font-label text-muted-foreground mb-1">Outcome</label>
|
||||
<select
|
||||
value={closeOutcome}
|
||||
onChange={(e) => setCloseOutcome(e.target.value as SessionOutcome)}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none mb-3"
|
||||
>
|
||||
<option value="">Select outcome...</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="escalated">Escalated</option>
|
||||
<option value="workaround">Workaround</option>
|
||||
<option value="unresolved">Unresolved</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="resolved_externally">Resolved Externally</option>
|
||||
</select>
|
||||
|
||||
<label className="block text-xs font-label text-muted-foreground mb-1">Notes (optional)</label>
|
||||
<textarea
|
||||
value={closeNotes}
|
||||
onChange={(e) => setCloseNotes(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Add closure notes..."
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none mb-3"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setClosingSessionId(null)
|
||||
setCloseOutcome('')
|
||||
setCloseNotes('')
|
||||
}}
|
||||
className="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCloseSession}
|
||||
disabled={!closeOutcome || closeLoading}
|
||||
className={cn(
|
||||
'rounded-lg px-4 py-1.5 text-sm font-medium shadow-lg shadow-primary/20 transition-opacity',
|
||||
closeOutcome
|
||||
? 'bg-gradient-brand text-[#101114] hover:opacity-90'
|
||||
: 'bg-gradient-brand text-[#101114] opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{closeLoading ? 'Closing...' : 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Step 5: Verify build**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
|
||||
Expected: Clean build, no errors.
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/SessionHistoryPage.tsx
|
||||
git commit -m "feat: add close session button with inline popover on history page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Verify Full Stack
|
||||
|
||||
**Step 1: Run backend tests**
|
||||
|
||||
Run: `docker exec resolutionflow_backend pytest tests/test_sessions.py -v`
|
||||
|
||||
Expected: All tests PASS including the 2 new ones.
|
||||
|
||||
**Step 2: Run frontend build**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
|
||||
Expected: Clean build.
|
||||
|
||||
**Step 3: Manual smoke test**
|
||||
|
||||
1. Open http://localhost:5173/sessions
|
||||
2. Find an active session (yellow dot, no completed_at)
|
||||
3. Verify "Close" button appears between "View Details" and "Resume"
|
||||
4. Click "Close" — popover appears below
|
||||
5. Select "Cancelled" outcome, type a note
|
||||
6. Click "Confirm" — session card updates with completed status + "Cancelled" badge
|
||||
7. Verify "Close" and "Resume" buttons disappear (session is now completed)
|
||||
8. Verify completed sessions do NOT show a "Close" button
|
||||
9. Verify prepared sessions (not yet started) do NOT show a "Close" button
|
||||
|
||||
**Step 4: Final commit (if any adjustments needed)**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: session closure adjustments from smoke test"
|
||||
```
|
||||
520
docs/plans/KB-Accelerator-Design-Document.md
Normal file
520
docs/plans/KB-Accelerator-Design-Document.md
Normal file
@@ -0,0 +1,520 @@
|
||||
# RESOLUTIONFLOW — KB Accelerator
|
||||
|
||||
## Feature Design Document
|
||||
|
||||
*Transform static KB articles into interactive troubleshooting and procedural flows with AI-powered document analysis.*
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Document** | KB Accelerator — Feature Design & Architecture |
|
||||
| **Version** | 1.0 — Draft |
|
||||
| **Date** | March 2026 |
|
||||
| **Author** | ResolutionFlow LLC |
|
||||
| **Status** | Design Phase |
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. Executive Summary
|
||||
2. Problem Statement & Market Opportunity
|
||||
3. Feature Overview
|
||||
4. System Architecture
|
||||
5. AI Processing Pipeline
|
||||
6. Data Model
|
||||
7. API Design
|
||||
8. Frontend Design
|
||||
9. Supported Input Formats
|
||||
10. Conversion Intelligence
|
||||
11. Pricing & Tier Integration
|
||||
12. Build Phases & Roadmap
|
||||
13. Risk Analysis
|
||||
14. Success Metrics
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
KB Accelerator is a new feature for ResolutionFlow that allows MSP teams to upload their existing knowledge base articles and automatically convert them into interactive troubleshooting flows and procedural flows. This solves the cold-start adoption problem, transforms passive documentation into active troubleshooting tools, and delivers immediate value from day one.
|
||||
|
||||
> **Core Value Proposition**
|
||||
>
|
||||
> MSPs have years of institutional knowledge trapped in static Word docs, PDFs, and wiki articles that nobody reads mid-ticket. KB Accelerator transforms that content into the interactive, branching flows that ResolutionFlow is built around — turning dead documentation into living troubleshooting intelligence.
|
||||
|
||||
### Key Capabilities
|
||||
|
||||
- Upload KB articles in multiple formats (DOCX, PDF, HTML, Markdown, plain text, copy-paste)
|
||||
- AI-powered analysis that detects sequential steps, decision points, prerequisites, and resolution outcomes
|
||||
- Automatic mapping to ResolutionFlow's existing tree schema (troubleshooting flows) and procedural schema (procedure flows)
|
||||
- Intelligent detection of implicit branching logic buried in prose documentation
|
||||
- Draft flow output that lands directly in the flow editor for human review and refinement
|
||||
- Confidence scoring on each generated node so users know where AI interpretation needs attention
|
||||
- Batch import capability for migrating entire KB libraries
|
||||
|
||||
### Strategic Impact
|
||||
|
||||
| Impact Area | Description |
|
||||
|---|---|
|
||||
| **Adoption** | Eliminates cold-start problem. New users get a library of draft flows on day one instead of building from scratch. |
|
||||
| **Retention** | Users who import existing KB articles are investing their institutional knowledge into the platform, increasing switching costs. |
|
||||
| **Revenue** | Pro/Team-gated feature that directly justifies subscription pricing. AI processing costs are per-conversion, aligning expense with usage. |
|
||||
| **Differentiation** | No competing MSP documentation tool offers AI-powered conversion from static docs to interactive decision trees. |
|
||||
| **Thesis Validation** | Proves the core ResolutionFlow thesis: documentation and troubleshooting should be the same activity. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Problem Statement & Market Opportunity
|
||||
|
||||
### 2.1 The KB Problem in MSPs
|
||||
|
||||
Every MSP has knowledge base articles. They live in ConnectWise, IT Glue, Hudu, SharePoint, Confluence, or simply as Word documents on a shared drive. These articles represent years of accumulated troubleshooting experience and process documentation. The problem is that this content is fundamentally passive. It exists as prose that a technician has to read, interpret, and mentally convert into action steps while simultaneously working a live ticket.
|
||||
|
||||
The result is predictable: technicians don't read the KB articles. They ask a senior engineer instead, or they fumble through the issue on their own. The documentation exists but delivers no value because the format doesn't match the workflow.
|
||||
|
||||
### 2.2 The Cold-Start Problem
|
||||
|
||||
ResolutionFlow solves the format problem by making documentation interactive. But it introduces a new problem: a new ResolutionFlow customer has zero flows. Building troubleshooting trees from scratch is time-consuming. The customer has to invest significant effort before they see value, and most MSPs don't have that patience.
|
||||
|
||||
> **The Gap**
|
||||
>
|
||||
> MSPs have the knowledge (in KB articles). ResolutionFlow has the format (interactive flows). KB Accelerator bridges the gap by converting one into the other automatically.
|
||||
|
||||
### 2.3 Competitive Landscape
|
||||
|
||||
No MSP-focused tool currently offers AI-powered conversion from static documentation to interactive troubleshooting workflows. IT Glue and Hudu offer structured documentation but no interactive execution. ConnectWise's KB is search-based and static. This is a genuine whitespace opportunity.
|
||||
|
||||
---
|
||||
|
||||
## 3. Feature Overview
|
||||
|
||||
### 3.1 User Journey
|
||||
|
||||
1. User navigates to KB Accelerator from the ResolutionFlow dashboard (dedicated tab or sidebar action).
|
||||
2. User uploads a file (DOCX, PDF, HTML, MD, TXT) or pastes raw text content directly.
|
||||
3. System analyzes the document structure and displays a preview of detected elements: title, problem statement, steps, decision points, prerequisites, and resolution outcomes.
|
||||
4. User selects target flow type: Troubleshooting Flow (branching decision tree) or Procedure Flow (linear steps with optional intake form).
|
||||
5. AI processing pipeline generates a draft flow mapped to ResolutionFlow's schema.
|
||||
6. User reviews the draft in a side-by-side view: original document on the left, generated flow preview on the right.
|
||||
7. User can accept, edit, or regenerate individual nodes before finalizing.
|
||||
8. Finalized flow is saved and appears in the user's flow library, ready for use or further editing in the standard flow editor.
|
||||
|
||||
### 3.2 Two Conversion Modes
|
||||
|
||||
**Troubleshooting Flow Conversion**
|
||||
|
||||
Best for: diagnostic articles, if/then troubleshooting guides, articles with multiple resolution paths.
|
||||
|
||||
- AI identifies the root question or symptom being diagnosed
|
||||
- Decision nodes are created at each branching point ("if X, try Y; otherwise try Z")
|
||||
- Resolution nodes capture final outcomes and fix instructions
|
||||
- The branching tree maps to the existing node/option schema in the trees table
|
||||
|
||||
**Procedure Flow Conversion**
|
||||
|
||||
Best for: step-by-step guides, setup procedures, onboarding checklists, runbooks.
|
||||
|
||||
- AI extracts sequential steps in order
|
||||
- Variable placeholders are detected (server names, IPs, usernames) and mapped to `[VAR:name]` tokens
|
||||
- An intake form schema is auto-generated from detected variables
|
||||
- Steps are enriched with detected warnings, time estimates, and verification checks
|
||||
- Output uses the procedural `tree_type` with the `intake_form` JSONB schema from migration 035
|
||||
|
||||
---
|
||||
|
||||
## 4. System Architecture
|
||||
|
||||
### 4.1 High-Level Architecture
|
||||
|
||||
KB Accelerator integrates into the existing ResolutionFlow stack without introducing new infrastructure. It leverages the FastAPI backend for orchestration, the existing AI service (same infrastructure as the assistant chat) for document analysis, and outputs directly into the existing tree/node schema.
|
||||
|
||||
**Processing Pipeline Overview**
|
||||
|
||||
```
|
||||
1. UPLOAD → 2. EXTRACT → 3. ANALYZE → 4. GENERATE → 5. REVIEW
|
||||
File upload or Text extraction AI analysis of Map to tree/ Side-by-side
|
||||
text paste via from DOCX/PDF/ structure, steps, node schema or review, edit,
|
||||
API endpoint HTML/MD/TXT decision points procedure schema and finalize
|
||||
```
|
||||
|
||||
### 4.2 Component Responsibilities
|
||||
|
||||
| Component | Responsibility | Integration Point |
|
||||
|---|---|---|
|
||||
| **Upload Service** | File validation, format detection, size limits, virus scanning hook | FastAPI endpoint, S3/local temp storage |
|
||||
| **Extraction Service** | Convert uploaded files to normalized plain text with structural metadata | python-docx, PyMuPDF, BeautifulSoup, markdown-it |
|
||||
| **AI Analysis Service** | Prompt engineering pipeline that identifies document structure and converts to flow schema | Anthropic API (Claude), existing AI service infrastructure |
|
||||
| **Flow Generator** | Maps AI analysis output to tree/node database records with proper relationships | SQLAlchemy models, existing tree/node CRUD services |
|
||||
| **Review UI** | Side-by-side document vs. flow preview with per-node editing | React frontend, existing flow editor components |
|
||||
| **Batch Processor** | Queue-based processing for multi-article imports | Celery/Redis or async FastAPI background tasks |
|
||||
|
||||
---
|
||||
|
||||
## 5. AI Processing Pipeline
|
||||
|
||||
The AI pipeline is the core intelligence of KB Accelerator. It operates in two phases: structural analysis (understanding the document) and flow generation (converting that understanding into a ResolutionFlow-compatible schema). This two-phase approach allows for human review between analysis and generation.
|
||||
|
||||
### 5.1 Phase 1: Document Analysis
|
||||
|
||||
The first AI call analyzes the extracted text and returns a structured JSON document describing what was found. The prompt is carefully engineered to identify MSP-specific patterns.
|
||||
|
||||
**Analysis Prompt Strategy**
|
||||
|
||||
- System prompt establishes the AI as an MSP documentation specialist that understands IT troubleshooting workflows
|
||||
- The extracted text is provided with any structural metadata (headings, lists, numbered steps) preserved
|
||||
- AI is instructed to return a strict JSON schema identifying: document type, title, problem statement, prerequisites, sequential steps, decision points, resolution outcomes, and detected variables
|
||||
|
||||
**Detection Targets**
|
||||
|
||||
| Element | What AI Looks For | Example in KB Article |
|
||||
|---|---|---|
|
||||
| **Document Type** | Whether the article is diagnostic (troubleshooting) or procedural (step-by-step) | *"Troubleshooting Outlook connectivity" vs "Setting up a new domain controller"* |
|
||||
| **Problem Statement** | The root issue or task being addressed | *"Users report that Outlook keeps disconnecting from Exchange"* |
|
||||
| **Prerequisites** | Things that must be true before starting | *"Ensure you have Domain Admin credentials and the server is on the network"* |
|
||||
| **Sequential Steps** | Ordered instructions that must happen in sequence | *"Step 1: Open Server Manager. Step 2: Add Roles and Features..."* |
|
||||
| **Decision Points** | Conditional logic, if/then/else branches | *"If the user is on Windows 10, check the registry. On Windows 11, go to Settings..."* |
|
||||
| **Variables** | Instance-specific values that change per execution | *Server names, IP addresses, usernames, license types, domain names* |
|
||||
| **Warnings/Cautions** | Risk indicators or critical notes | *"WARNING: This will restart the DNS service and cause brief connectivity loss"* |
|
||||
| **Resolution Outcomes** | End states that indicate the problem is solved | *"Outlook should now maintain a persistent connection to Exchange"* |
|
||||
| **Verification Steps** | How to confirm a step or procedure worked | *"Run nslookup to verify DNS resolution is working correctly"* |
|
||||
|
||||
### 5.2 Phase 2: Flow Generation
|
||||
|
||||
The second AI call takes the structured analysis from Phase 1 and generates the actual flow structure, mapped directly to ResolutionFlow's schema. The output format differs based on the target flow type.
|
||||
|
||||
**Troubleshooting Flow Output**
|
||||
|
||||
- Root node with the problem statement as the question text
|
||||
- Decision nodes with options array matching the existing node schema (question, options with label and next_node_id)
|
||||
- Resolution nodes at leaf positions with solution text and tags from the six-dimension tagging system
|
||||
- Each node includes a `confidence_score` (0.0–1.0) indicating how certain the AI is about the mapping
|
||||
|
||||
**Procedure Flow Output**
|
||||
|
||||
- Ordered steps array with rich metadata (type, content, warnings, time estimates, verification checks)
|
||||
- Auto-generated `intake_form` schema from detected variables, with field types inferred (text for names, ip_address for IPs, select for known enumerations)
|
||||
- `[VAR:name]` tokens injected into step content wherever variables were detected
|
||||
- Section headers generated from logical groupings in the source document
|
||||
|
||||
### 5.3 Confidence Scoring
|
||||
|
||||
Every generated node includes a confidence score that communicates how certain the AI is about its interpretation. This is critical for the review step — it tells the user exactly where to focus their attention.
|
||||
|
||||
| Score Range | Label | UI Indicator | Meaning |
|
||||
|---|---|---|---|
|
||||
| **0.9 – 1.0** | High Confidence | Green left accent | Direct mapping from explicit steps or clear logic in the source |
|
||||
| **0.7 – 0.89** | Medium Confidence | Amber left accent | Reasonable inference, but some ambiguity in the source material |
|
||||
| **0.5 – 0.69** | Low Confidence | Red left accent | Significant interpretation required; user should carefully review |
|
||||
| **< 0.5** | Needs Review | Red left accent + flag icon | AI made a best guess but recommends manual editing |
|
||||
|
||||
> **Design Note: Left Accent Border Pattern**
|
||||
>
|
||||
> The confidence indicators use the left accent border pattern established in the ResolutionFlow design system. This provides visual consistency with step status indicators and documentation callouts already in the UI.
|
||||
|
||||
---
|
||||
|
||||
## 6. Data Model
|
||||
|
||||
KB Accelerator introduces two new database tables and extends the existing tree model. All new tables follow the existing migration pattern and use the same base model infrastructure.
|
||||
|
||||
### 6.1 New Table: `kb_imports`
|
||||
|
||||
| Column | Type | Nullable | Description |
|
||||
|---|---|---|---|
|
||||
| **id** | UUID | No | Primary key (gen_random_uuid) |
|
||||
| **organization_id** | UUID FK | No | Foreign key to organizations table |
|
||||
| **created_by** | UUID FK | No | Foreign key to users table (who initiated the import) |
|
||||
| **source_filename** | VARCHAR(500) | Yes | Original filename if file upload (null for text paste) |
|
||||
| **source_format** | VARCHAR(20) | No | Enum: docx, pdf, html, md, txt, paste |
|
||||
| **source_text** | TEXT | No | Extracted plain text content from the source document |
|
||||
| **source_metadata** | JSONB | Yes | Structural metadata from extraction (headings, lists, etc.) |
|
||||
| **analysis_result** | JSONB | Yes | Phase 1 AI analysis output (detected elements) |
|
||||
| **target_type** | VARCHAR(20) | No | Enum: troubleshooting, procedural |
|
||||
| **generated_flow** | JSONB | Yes | Phase 2 AI generation output (flow schema before commit) |
|
||||
| **tree_id** | UUID FK | Yes | Foreign key to trees table (set after user finalizes) |
|
||||
| **status** | VARCHAR(20) | No | Enum: uploaded, extracting, analyzing, reviewed, generating, completed, failed |
|
||||
| **confidence_avg** | FLOAT | Yes | Average confidence score across all generated nodes |
|
||||
| **error_message** | TEXT | Yes | Error details if status = failed |
|
||||
| **processing_time_ms** | INTEGER | Yes | Total processing time in milliseconds |
|
||||
| **created_at** | TIMESTAMPTZ | No | Auto-set on creation |
|
||||
| **updated_at** | TIMESTAMPTZ | No | Auto-updated on modification |
|
||||
|
||||
### 6.2 New Table: `kb_import_nodes`
|
||||
|
||||
Stores individual generated nodes/steps before the user commits them to the actual tree. This allows per-node editing during the review phase without touching the live flow data.
|
||||
|
||||
| Column | Type | Nullable | Description |
|
||||
|---|---|---|---|
|
||||
| **id** | UUID | No | Primary key |
|
||||
| **kb_import_id** | UUID FK | No | Foreign key to kb_imports |
|
||||
| **node_order** | INTEGER | No | Position in the generated flow (0-indexed) |
|
||||
| **node_type** | VARCHAR(20) | No | Enum: question, resolution, step, section_header, warning |
|
||||
| **content** | JSONB | No | Node content (question text, step text, options, etc.) |
|
||||
| **source_excerpt** | TEXT | Yes | The specific text from the source document that this node was derived from |
|
||||
| **confidence_score** | FLOAT | No | AI confidence in this node's accuracy (0.0–1.0) |
|
||||
| **user_edited** | BOOLEAN | No | Whether the user manually modified this node during review |
|
||||
| **user_approved** | BOOLEAN | No | Whether the user explicitly approved this node |
|
||||
|
||||
### 6.3 Tree Model Extension
|
||||
|
||||
The existing trees table gets one new nullable column to link back to the import that created it. This enables analytics and provenance tracking.
|
||||
|
||||
**New column:** `kb_import_id` (UUID FK, nullable) — references `kb_imports.id`. Null for manually-created trees.
|
||||
|
||||
---
|
||||
|
||||
## 7. API Design
|
||||
|
||||
All KB Accelerator endpoints live under the `/api/v1/kb-accelerator` prefix and follow existing authentication, organization scoping, and error handling patterns.
|
||||
|
||||
### 7.1 Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|---|---|---|
|
||||
| **POST** | `/api/v1/kb-accelerator/upload` | Upload a file or submit pasted text. Returns kb_import_id and starts extraction. |
|
||||
| **GET** | `/api/v1/kb-accelerator/{id}` | Get import status, analysis results, and generated flow data. |
|
||||
| **GET** | `/api/v1/kb-accelerator` | List all imports for the current organization with pagination and status filter. |
|
||||
| **POST** | `/api/v1/kb-accelerator/{id}/analyze` | Trigger Phase 1 AI analysis on extracted text. Async — poll status via GET. |
|
||||
| **POST** | `/api/v1/kb-accelerator/{id}/generate` | Trigger Phase 2 flow generation from analysis results. Requires target_type. |
|
||||
| **PATCH** | `/api/v1/kb-accelerator/{id}/nodes/{node_id}` | Edit a specific generated node during review (content, approve, reject). |
|
||||
| **POST** | `/api/v1/kb-accelerator/{id}/commit` | Finalize the import: create actual tree and node records from generated data. |
|
||||
| **DELETE** | `/api/v1/kb-accelerator/{id}` | Cancel and clean up an in-progress or abandoned import. |
|
||||
| **POST** | `/api/v1/kb-accelerator/batch` | Submit multiple files for batch processing. Returns array of kb_import_ids. |
|
||||
|
||||
### 7.2 Upload Endpoint Detail
|
||||
|
||||
**POST /api/v1/kb-accelerator/upload**
|
||||
|
||||
Accepts multipart/form-data for file uploads or application/json for text paste. Validates file size (max 10MB), format, and performs basic content extraction before returning.
|
||||
|
||||
**Request Body (File Upload)**
|
||||
|
||||
- **file**: UploadFile (required) — the KB article file
|
||||
- **target_type**: string (optional) — "troubleshooting" or "procedural" (can be set later)
|
||||
|
||||
**Request Body (Text Paste)**
|
||||
|
||||
- **content**: string (required) — raw text content
|
||||
- **title**: string (optional) — suggested title for the import
|
||||
- **target_type**: string (optional) — "troubleshooting" or "procedural"
|
||||
|
||||
**Response (201 Created)**
|
||||
|
||||
- **id**: UUID — the new kb_import record ID
|
||||
- **status**: "uploaded" or "extracting" (extraction may start immediately)
|
||||
- **source_format**: detected format of the uploaded content
|
||||
|
||||
---
|
||||
|
||||
## 8. Frontend Design
|
||||
|
||||
### 8.1 Entry Points
|
||||
|
||||
KB Accelerator is accessible from two locations in the existing UI to maximize discoverability:
|
||||
|
||||
- **Dashboard action button:** a prominent "Import KB Article" button in the flow library header, next to "Create New Flow"
|
||||
- **Sidebar navigation:** dedicated "KB Accelerator" item in the main navigation with a sparkle/lightning icon to communicate AI-powered functionality
|
||||
|
||||
### 8.2 Upload Screen
|
||||
|
||||
Clean, focused upload interface with two input modes:
|
||||
|
||||
- Drag-and-drop zone for file uploads with format badges showing supported types (DOCX, PDF, HTML, MD, TXT)
|
||||
- Text paste tab with a full-width textarea and title field for direct content entry
|
||||
- Target type selector (Troubleshooting Flow / Procedure Flow) with visual cards showing the difference
|
||||
- "Let AI decide" option for target type that uses the analysis phase to recommend the best fit
|
||||
|
||||
### 8.3 Analysis Preview Screen
|
||||
|
||||
After Phase 1 analysis completes, the user sees a breakdown of what the AI detected:
|
||||
|
||||
- Document title and detected type with AI recommendation badge
|
||||
- Detected elements displayed as color-coded cards: steps (blue), decision points (amber), warnings (red), variables (green), resolutions (emerald)
|
||||
- Source text excerpts linked to each detected element so the user can see exactly what triggered the detection
|
||||
- "Proceed to Generation" and "Re-analyze" action buttons
|
||||
|
||||
### 8.4 Review Screen (Core Experience)
|
||||
|
||||
The review screen is the most important UI in KB Accelerator. It's where the user validates AI output and builds trust in the system.
|
||||
|
||||
**Layout: Two-Panel Side-by-Side**
|
||||
|
||||
- Left panel: Original document text with detected elements highlighted inline (color-matched to the generated nodes)
|
||||
- Right panel: Generated flow preview showing the tree structure (for troubleshooting) or step list (for procedures)
|
||||
- Clicking a node in the right panel highlights its source excerpt in the left panel, and vice versa
|
||||
- Each node shows its confidence score via the left accent border pattern (green/amber/red)
|
||||
|
||||
**Per-Node Actions**
|
||||
|
||||
- **Approve** (checkmark): Marks the node as reviewed and accepted
|
||||
- **Edit** (pencil): Opens inline editing for the node's content, question text, options, etc.
|
||||
- **Regenerate** (refresh): Re-runs AI generation for just this node with optional user guidance
|
||||
- **Delete** (trash): Removes the node from the generated flow
|
||||
- **Add Node** (plus): Insert a manual node between existing ones
|
||||
|
||||
**Bulk Actions**
|
||||
|
||||
- "Approve All High Confidence" — one-click approval for all nodes scoring 0.9+
|
||||
- "Commit to Library" — finalizes the flow and creates the actual tree record
|
||||
|
||||
> **UI Principle**
|
||||
>
|
||||
> The review screen should feel like a code review, not a form. The user is reviewing AI-generated work with the power to accept, modify, or reject each piece. The side-by-side layout with source attribution builds trust by showing the AI's reasoning.
|
||||
|
||||
---
|
||||
|
||||
## 9. Supported Input Formats
|
||||
|
||||
| Format | Library | Structure Preserved | Notes |
|
||||
|---|---|---|---|
|
||||
| **DOCX** | python-docx | Headings, lists, tables, bold/italic emphasis | Most common format for MSP KB articles in SharePoint and shared drives |
|
||||
| **PDF** | PyMuPDF (fitz) | Text extraction with layout awareness, headings via font size | Second most common; handles scanned docs with OCR fallback via Tesseract |
|
||||
| **HTML** | BeautifulSoup | Full semantic structure (h1-h6, ul/ol, tables, code blocks) | Covers Confluence, IT Glue, and web-based KB exports |
|
||||
| **Markdown** | markdown-it | Headings, lists, code blocks, emphasis, links | Common in developer-oriented documentation and GitHub repos |
|
||||
| **Plain Text** | Built-in | Line breaks and indentation only; AI infers structure | Lowest fidelity but important for copy-paste and email-sourced docs |
|
||||
| **Paste** | Built-in | None — AI infers all structure from content | Zero-friction entry point for quick conversions |
|
||||
|
||||
> **Extraction Quality Hierarchy**
|
||||
>
|
||||
> DOCX and HTML provide the richest structural metadata, giving the AI the most to work with. PDF extraction is good but lossy (formatting information is approximate). Plain text and paste require the AI to infer all structure from content alone, which reduces confidence scores but still produces usable output for well-written articles.
|
||||
|
||||
---
|
||||
|
||||
## 10. Conversion Intelligence
|
||||
|
||||
This section details the specific AI patterns and heuristics used to convert different types of KB content into flows. This is where KB Accelerator's real value lives — the ability to interpret messy, inconsistent MSP documentation and produce structured, actionable flows.
|
||||
|
||||
### 10.1 Detecting Implicit Branch Logic
|
||||
|
||||
The hardest challenge is identifying decision points that aren't explicitly written as if/then statements. MSP KB articles often bury branching logic in prose.
|
||||
|
||||
**Pattern Examples**
|
||||
|
||||
| KB Article Text | AI Interpretation |
|
||||
|---|---|
|
||||
| *"For Windows 10 machines, navigate to Settings > Update. For Windows 11, go to Settings > Windows Update."* | Decision node: "What Windows version?" with two branches leading to different step sequences |
|
||||
| *"If the issue persists after restarting the service, escalate to Tier 2."* | Decision node after the restart step: "Did the restart resolve the issue?" with Yes (resolution) and No (escalation) paths |
|
||||
| *"Note: Domain-joined computers use Group Policy. Workgroup computers need manual configuration."* | Decision node: "Is the computer domain-joined?" with two parallel procedure paths |
|
||||
| *"Try clearing the DNS cache first. If that doesn't work, check the hosts file."* | Sequential diagnostic flow: DNS cache clear > verification > hosts file check, with early exit if first step resolves |
|
||||
|
||||
### 10.2 Variable Detection
|
||||
|
||||
For procedure flow conversion, the AI identifies values that would change between executions and maps them to `[VAR:name]` tokens with appropriate intake form field types.
|
||||
|
||||
| Detected Pattern | Variable Name | Form Field Type | Example Value |
|
||||
|---|---|---|---|
|
||||
| IP addresses (192.168.x.x) | `[VAR:ip_address]` | ip_address | 192.168.1.10 |
|
||||
| Server/computer names | `[VAR:server_name]` | text | DC01 |
|
||||
| Domain names | `[VAR:domain_name]` | text | contoso.local |
|
||||
| Usernames/email | `[VAR:username]` | text | jsmith@contoso.com |
|
||||
| License types | `[VAR:license_type]` | select (enum) | E3, E5, F1 |
|
||||
| OU paths | `[VAR:ou_path]` | text | OU=Users,DC=contoso,DC=local |
|
||||
| Port numbers | `[VAR:port]` | number | 443 |
|
||||
| Subnet masks | `[VAR:subnet_mask]` | ip_address | 255.255.255.0 |
|
||||
|
||||
---
|
||||
|
||||
## 11. Pricing & Tier Integration
|
||||
|
||||
KB Accelerator is a premium feature that justifies Pro and Team subscription pricing. The AI processing has a real per-conversion cost (Anthropic API usage), so tiering aligns expense with revenue.
|
||||
|
||||
| Capability | Free | Pro ($19/mo) | Team ($15/user/mo) |
|
||||
|---|---|---|---|
|
||||
| **Single article import** | 3 lifetime conversions | Unlimited | Unlimited |
|
||||
| **Batch import** | Not available | Up to 10 articles | Up to 50 articles |
|
||||
| **Text paste** | Included in 3 conversions | Unlimited | Unlimited |
|
||||
| **Target type selection** | AI decides only | Manual + AI | Manual + AI |
|
||||
| **Review & edit** | Basic (approve/reject) | Full (edit, regenerate, add) | Full + team review |
|
||||
| **Confidence scoring** | Shown | Shown + filter/sort | Shown + filter/sort |
|
||||
| **Import history** | Last 3 only | Full history | Full history + audit log |
|
||||
| **Supported formats** | TXT and paste only | All formats | All formats |
|
||||
|
||||
> **Free Tier Strategy**
|
||||
>
|
||||
> The free tier offers 3 lifetime conversions with limited formats. This is enough for a user to experience the value and see KB Accelerator work on their actual documentation. The restriction to TXT/paste on free tier also reduces extraction library dependencies for free-tier infrastructure cost optimization.
|
||||
|
||||
---
|
||||
|
||||
## 12. Build Phases & Roadmap
|
||||
|
||||
### Phase 1: Foundation (Weeks 1–3)
|
||||
|
||||
Core pipeline with single-article import and basic review.
|
||||
|
||||
- Database migrations: `kb_imports` and `kb_import_nodes` tables, tree model extension
|
||||
- Upload endpoint with text paste and TXT file support
|
||||
- Text extraction service (plain text only in Phase 1)
|
||||
- AI analysis prompt engineering and Phase 1 pipeline
|
||||
- AI generation prompt engineering and Phase 2 pipeline (troubleshooting flow output only)
|
||||
- Basic review UI: list view of generated nodes with approve/reject
|
||||
- Commit endpoint that creates actual tree/node records
|
||||
|
||||
### Phase 2: Rich Formats & Procedures (Weeks 4–6)
|
||||
|
||||
Full format support and procedure flow conversion.
|
||||
|
||||
- DOCX extraction via python-docx with structural metadata
|
||||
- PDF extraction via PyMuPDF with layout analysis
|
||||
- HTML extraction via BeautifulSoup
|
||||
- Markdown extraction via markdown-it
|
||||
- Procedure flow generation with variable detection and intake form generation
|
||||
- Side-by-side review UI with source-to-node linking
|
||||
- Per-node editing and regeneration in the review screen
|
||||
- Confidence scoring visualization with left accent borders
|
||||
|
||||
### Phase 3: Polish & Scale (Weeks 7–9)
|
||||
|
||||
Batch import, UX refinement, and tier enforcement.
|
||||
|
||||
- Batch upload endpoint and queue processing
|
||||
- Import history dashboard with status tracking
|
||||
- Tier gating enforcement (free tier limits, format restrictions)
|
||||
- "Approve All High Confidence" bulk action
|
||||
- Analytics: conversion success rate, average confidence, most-used source formats
|
||||
- Drag-and-drop file upload zone with format badges
|
||||
- "Let AI decide" target type recommendation
|
||||
|
||||
### Phase 4: Advanced Intelligence (Future)
|
||||
|
||||
Stretch goals and post-launch enhancements.
|
||||
|
||||
- OCR fallback for scanned PDFs via Tesseract
|
||||
- Multi-article correlation: detect when multiple KB articles describe the same issue from different angles and suggest merging
|
||||
- Incremental re-import: detect when a source KB article has been updated and suggest flow updates
|
||||
- ConnectWise/IT Glue/Hudu direct API integration for pulling articles without manual export
|
||||
- Tag auto-assignment using the six-dimension tagging system based on article content analysis
|
||||
- Template marketplace: share anonymized, high-confidence converted flows with the ResolutionFlow community
|
||||
|
||||
---
|
||||
|
||||
## 13. Risk Analysis
|
||||
|
||||
| Risk | Severity | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| **Poor quality KB input** | Medium | AI produces low-confidence flows that require extensive manual editing, reducing perceived value | Confidence scoring sets expectations. "Needs Review" flags prevent silent bad output. Free tier lets users test before committing. |
|
||||
| **AI hallucination in flow logic** | High | Generated flows contain incorrect troubleshooting paths that could lead technicians astray | Mandatory review step before commit. Source attribution shows exact text the AI based each node on. No auto-publish. |
|
||||
| **API cost overruns** | Medium | High usage of AI analysis burns through API budget faster than subscription revenue covers | Per-conversion cost tracking. Tier limits on batch size. Prompt optimization to minimize token usage. |
|
||||
| **Extraction library maintenance** | Low | python-docx, PyMuPDF, etc. may have breaking changes or security issues | Pin versions. Phase 1 starts with text-only to defer library dependency. Each format is an independent module. |
|
||||
| **User trust gap** | High | Users don't trust AI-generated flows and abandon the feature after trying it once | Side-by-side source view builds trust. Confidence scoring is transparent. Start with high-quality conversion on well-structured articles to build initial trust. |
|
||||
| **Scope creep** | Medium | Feature grows to include direct PSA integration, real-time sync, and other complex functionality before core is proven | Phased roadmap with clear scope boundaries. Phase 4 items are explicitly deferred until post-launch data validates demand. |
|
||||
|
||||
---
|
||||
|
||||
## 14. Success Metrics
|
||||
|
||||
These KPIs determine whether KB Accelerator is delivering value to users and justifying its development investment.
|
||||
|
||||
| Metric | Target | Why It Matters |
|
||||
|---|---|---|
|
||||
| **Conversion completion rate** | > 70% | Percentage of started imports that reach "committed" status. Below 70% suggests the review step is too burdensome or quality is too low. |
|
||||
| **Average confidence score** | > 0.75 | Across all generated nodes. Indicates the AI pipeline is producing reliably accurate output. |
|
||||
| **Time from upload to commit** | < 10 minutes | The full cycle should feel fast. If users are spending 30+ minutes editing, the AI isn't saving enough time. |
|
||||
| **Free-to-Pro conversion rate** | > 15% | Users who use their 3 free conversions and then upgrade. This validates that experiencing the feature drives subscription revenue. |
|
||||
| **Repeat usage (Pro/Team)** | > 3 imports/month | Users who import once and never again didn't find sustained value. Repeat usage indicates the feature is part of their workflow. |
|
||||
| **Node edit rate** | < 30% | Percentage of generated nodes that users edit before committing. Lower is better — means AI output is usable as-is. |
|
||||
| **Imported flow usage rate** | > 50% | Percentage of committed flows that get used in actual troubleshooting sessions within 30 days. Unused flows mean the conversion produced shelfware. |
|
||||
|
||||
---
|
||||
|
||||
*End of Document*
|
||||
|
||||
ResolutionFlow LLC — March 2026
|
||||
628
docs/plans/KB-Accelerator-Merged-Implementation-Plan.md
Normal file
628
docs/plans/KB-Accelerator-Merged-Implementation-Plan.md
Normal file
@@ -0,0 +1,628 @@
|
||||
# KB Accelerator — Merged Implementation Plan
|
||||
|
||||
## Document Context
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Document** | KB Accelerator — Merged Implementation Plan |
|
||||
| **Version** | 1.0 |
|
||||
| **Date** | March 2026 |
|
||||
| **Status** | Approved for Implementation |
|
||||
| **Source Plans** | Claude Code design review + Codex implementation plan |
|
||||
| **Design Doc** | `docs/plans/KB-Accelerator-Design-Document.md` |
|
||||
|
||||
This plan merges the best elements of two independent implementation plans produced by Claude Code and Codex against the KB Accelerator design document. Where the plans conflicted, explicit decisions were made and are documented below.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary of Decisions
|
||||
|
||||
### Agreed by Both Plans (Carry Forward As-Is)
|
||||
|
||||
- Dedicated KB Accelerator frontend experience — own route (`/kb-accelerator`), own sidebar nav item, own screens
|
||||
- `account_id` tenancy everywhere — all design doc references to "organization" map to existing `account_id`
|
||||
- Text + paste + DOCX in Phase 1; PDF, HTML, Markdown in Phase 2
|
||||
- Both flow types (troubleshooting + procedural) supported from Phase 1
|
||||
- Single-phase AI conversion by default; optional detailed analysis for Pro/Team
|
||||
- 3 lifetime conversions for free tier, enforced per account (not per user)
|
||||
- Hard server-side tier enforcement via PlanLimits columns
|
||||
- Store extracted text + metadata only — raw uploaded files are not persisted
|
||||
- File validation + pluggable scan hook interface (no-op default, AV integration ready)
|
||||
- Per-node review actions: approve, edit, delete, regenerate, insert, plus bulk approve
|
||||
- Side-by-side two-panel review UI with confidence indicators (green/amber/red left accent borders)
|
||||
- `import_metadata` JSONB on trees table for provenance — no new FK column on trees
|
||||
- HTTP polling for progress tracking (no SSE, no WebSockets)
|
||||
- Multipart `files[]` + shared options for batch upload request shape (Phase 3)
|
||||
- Auto-advance pipeline: upload → extraction → AI conversion → land on review screen (no manual stage gates)
|
||||
- Auto-commit as draft for batch imports (Phase 3)
|
||||
- Feature-flagged analysis preview screen (Pro/Team only)
|
||||
- Basic shared visibility for Team tier (view/read, not collaborative editing)
|
||||
- Sidebar nav item + "Import KB Article" CTA in flow library header
|
||||
|
||||
### Conflict Resolutions
|
||||
|
||||
| Decision | Chosen Approach | Rationale |
|
||||
|---|---|---|
|
||||
| **AI Infrastructure** | **Codex: Dedicated KB module** consuming shared AI service layer (model routing, token tracking, quota). NOT coupled to `AIChatSession`. | A KB import is a document conversion, not a chat session. Coupling to `AIChatSession` muddies analytics, session history, and data model semantics. Using shared AI *services* without coupling to the AI *data model* is the right separation. |
|
||||
| **Per-node staging** | **Codex: Dedicated `kb_import_nodes` table** with proper columns for confidence, source excerpt, approval status. | Queryable (e.g., "all nodes below 0.7 confidence across imports"), normalized, clean PATCH semantics. Avoids the `_kb_meta` JSONB prefix hack which is fragile and risks junk data in production trees if stripping is missed. |
|
||||
| **Batch import** | **Claude Code: Defer to Phase 3.** | Core single-article conversion must be validated first. Batch adds queue management, partial failure handling, and batch status UI — significant complexity for a feature nobody has requested yet. |
|
||||
| **Conversational refinement** | **Claude Code's idea, Codex's architecture. Defer to Phase 2.** Built as a scoped chat panel in the review screen, NOT coupled to `AIChatSession`. | High-value feature, but Phase 1 must nail the core loop (upload → convert → review → commit). Refinement panel in Phase 2 uses a dedicated KB chat endpoint scoped to the import context. |
|
||||
| **Step Library matching** | **Defer to Phase 2.** | Same reasoning — nail the core loop first, then layer on matching. |
|
||||
| **Status values** | **Claude Code: Simplified to 4** — `processing`, `ready`, `committed`, `failed`. | With single-phase AI and auto-advance, granular statuses (uploaded, extracting, analyzing, generating, reviewed) add complexity without user value. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture Overview
|
||||
|
||||
### Backend: Dedicated KB Module + Shared AI Services
|
||||
|
||||
KB Accelerator is a self-contained backend module with its own tables, endpoints, services, and business logic. It does NOT create or depend on `AIChatSession` records.
|
||||
|
||||
When AI processing is needed, the KB module calls the existing shared AI service layer:
|
||||
|
||||
- **Model routing** via `get_model_for_action()` — add `kb_convert` and `kb_analyze` to `ACTION_MODEL_MAP`
|
||||
- **Token tracking** via existing token counting utilities
|
||||
- **Quota enforcement** via `ai_quota_service` (`check_ai_quota`, `record_ai_usage`)
|
||||
- **Cost tracking** via existing cost recording patterns
|
||||
- **Anthropic API calls** via existing `AsyncAnthropic` client patterns
|
||||
|
||||
The KB module owns its own prompt engineering, extraction logic, pipeline orchestration, and data persistence.
|
||||
|
||||
### Frontend: Dedicated KB Accelerator Experience
|
||||
|
||||
The frontend is a standalone multi-step wizard UI under `/kb-accelerator`. Users never see "AI Chat" branding or feel like they've left KB Accelerator. The conversational refinement panel (Phase 2) is visually integrated into the KB review screen — it reuses `EditorAIPanel` component internals but is branded and scoped to the KB context.
|
||||
|
||||
### Processing Pipeline
|
||||
|
||||
```
|
||||
User uploads file/paste
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 1. UPLOAD │ Validate format, size, tier permissions
|
||||
│ & EXTRACT │ Extract text + structural metadata
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 2. CONVERT │ Single AI call → tree structure + confidence scores
|
||||
│ (AI) │ OR two-phase (Pro/Team optional): analyze → generate
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 3. REVIEW │ Side-by-side UI, per-node actions, edit/approve/delete
|
||||
│ (User) │ + Conversational refinement panel (Phase 2)
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 4. COMMIT │ Create Tree record, set import_metadata, strip staging data
|
||||
│ │ Step Library match suggestions (Phase 2)
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Model
|
||||
|
||||
### New Table: `kb_imports` (Migration 054)
|
||||
|
||||
| Column | Type | Nullable | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | UUID PK | No | Primary key (`gen_random_uuid()`) |
|
||||
| `account_id` | UUID FK → accounts | No | Tenancy scoping |
|
||||
| `created_by` | UUID FK → users | No | Who initiated the import |
|
||||
| `source_filename` | VARCHAR(500) | Yes | Original filename (null for paste) |
|
||||
| `source_format` | VARCHAR(20) | No | Enum: `txt`, `paste`, `docx` (Phase 1); `pdf`, `html`, `md` (Phase 2) |
|
||||
| `source_text` | TEXT | No | Extracted plain text content |
|
||||
| `source_metadata` | JSONB | Yes | Structural metadata from extraction (headings, lists, emphasis) |
|
||||
| `target_type` | VARCHAR(20) | No | Enum: `troubleshooting`, `procedural` |
|
||||
| `status` | VARCHAR(20) | No | Enum: `processing`, `ready`, `committed`, `failed` |
|
||||
| `confidence_avg` | FLOAT | Yes | Average confidence across all generated nodes |
|
||||
| `error_message` | TEXT | Yes | Error details if status = `failed` |
|
||||
| `processing_time_ms` | INTEGER | Yes | Total processing time in milliseconds |
|
||||
| `ai_tokens_input` | INTEGER | Yes | Total input tokens used for AI processing |
|
||||
| `ai_tokens_output` | INTEGER | Yes | Total output tokens used for AI processing |
|
||||
| `tree_id` | UUID FK → trees | Yes | Set after user commits (null until then) |
|
||||
| `batch_id` | UUID | Yes | Groups batch imports together (Phase 3) |
|
||||
| `created_at` | TIMESTAMPTZ | No | Auto-set on creation |
|
||||
| `updated_at` | TIMESTAMPTZ | No | Auto-updated on modification |
|
||||
|
||||
**Indexes:** `account_id`, `status`, `batch_id`, `created_by`, `created_at DESC`.
|
||||
|
||||
### New Table: `kb_import_nodes` (Migration 054)
|
||||
|
||||
Stores individual generated nodes/steps during the review phase. Each row represents one node in the AI-generated flow before the user commits it to an actual tree.
|
||||
|
||||
| Column | Type | Nullable | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | UUID PK | No | Primary key |
|
||||
| `kb_import_id` | UUID FK → kb_imports | No | Parent import |
|
||||
| `node_order` | INTEGER | No | Position in the generated flow (0-indexed) |
|
||||
| `node_type` | VARCHAR(20) | No | Enum: `question`, `resolution`, `step`, `section_header`, `warning` |
|
||||
| `content` | JSONB | No | Node content (question text, step text, options array, etc.) |
|
||||
| `parent_node_id` | UUID FK → kb_import_nodes | Yes | Parent node (for tree structure) |
|
||||
| `source_excerpt` | TEXT | Yes | Exact text from source document this node was derived from |
|
||||
| `confidence_score` | FLOAT | No | AI confidence in this node's accuracy (0.0–1.0) |
|
||||
| `user_edited` | BOOLEAN | No | Default `false`. Set `true` when user modifies content |
|
||||
| `user_approved` | BOOLEAN | No | Default `false`. Set `true` when user explicitly approves |
|
||||
| `created_at` | TIMESTAMPTZ | No | Auto-set on creation |
|
||||
| `updated_at` | TIMESTAMPTZ | No | Auto-updated on modification |
|
||||
|
||||
**Indexes:** `kb_import_id`, `confidence_score`.
|
||||
|
||||
### Tree `import_metadata` JSONB Schema (Set on Commit)
|
||||
|
||||
When a user commits a KB Accelerator flow, the resulting tree's `import_metadata` column is populated:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "kb_accelerator",
|
||||
"kb_import_id": "uuid-here",
|
||||
"source_filename": "Exchange-Troubleshooting.docx",
|
||||
"source_format": "docx",
|
||||
"confidence_avg": 0.85,
|
||||
"node_count": 12,
|
||||
"converted_at": "2026-03-10T14:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### PlanLimits Extensions
|
||||
|
||||
Add the following columns to the existing `plan_limits` table (and corresponding `account_limit_overrides`, admin schemas, subscription schemas, and frontend types):
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `kb_accelerator_enabled` | BOOLEAN | Whether KB Accelerator is available on this plan |
|
||||
| `kb_max_lifetime_conversions` | INTEGER, nullable | Lifetime cap (null = unlimited). Free = 3. |
|
||||
| `kb_batch_max_size` | INTEGER, nullable | Max files per batch upload (null = disabled). Phase 3. |
|
||||
| `kb_allowed_formats` | JSONB | Array of allowed format strings. Free = `["txt", "paste"]`. Pro/Team = all. |
|
||||
| `kb_detailed_analysis` | BOOLEAN | Whether optional two-phase analysis is available |
|
||||
| `kb_conversational_refinement` | BOOLEAN | Whether AI refinement panel is available (Phase 2) |
|
||||
| `kb_step_library_matching` | BOOLEAN | Whether Step Library matching is available (Phase 2) |
|
||||
| `kb_history_limit` | INTEGER, nullable | Max visible import history entries (null = unlimited). Free = 3. |
|
||||
|
||||
**Seed defaults:**
|
||||
|
||||
| Plan | enabled | lifetime_cap | batch_max | formats | detailed_analysis | refinement | step_matching | history_limit |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| **Free** | true | 3 | null | `["txt", "paste"]` | false | false | false | 3 |
|
||||
| **Pro** | true | null | 5 | `["txt", "paste", "docx", "pdf", "html", "md"]` | true | true | true | null |
|
||||
| **Team** | true | null | 10 | `["txt", "paste", "docx", "pdf", "html", "md"]` | true | true | true | null |
|
||||
|
||||
---
|
||||
|
||||
## 4. API Design
|
||||
|
||||
All endpoints under `/api/v1/kb-accelerator`. All require authentication. All records scoped to `account_id`. Role enforcement: `require_engineer_or_admin`.
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Method | Endpoint | Description | Phase |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/upload` | Upload file or paste text. Creates `kb_import`, starts extraction, triggers auto-convert. Returns `kb_import_id`. | 1 |
|
||||
| `GET` | `/{id}` | Get import status, source text preview, generated nodes, confidence stats. | 1 |
|
||||
| `GET` | `/` | List imports for current account. Pagination + status filter. Respects `kb_history_limit`. | 1 |
|
||||
| `POST` | `/{id}/convert` | Manually trigger or re-trigger AI conversion. For retry/regeneration scenarios. | 1 |
|
||||
| `PATCH` | `/{id}/nodes/{node_id}` | Edit a specific node. Operations: `approve`, `reject`, `edit`, `delete`, `regenerate`, `insert_after`. | 1 |
|
||||
| `POST` | `/{id}/commit` | Finalize: create Tree record from reviewed nodes, populate `import_metadata`, update status to `committed`. | 1 |
|
||||
| `DELETE` | `/{id}` | Cancel and clean up an in-progress or abandoned import. | 1 |
|
||||
| `GET` | `/quota` | Return current plan KB entitlements, usage counts, and UI flags (detailed_analysis, refinement, etc.). | 1 |
|
||||
| `POST` | `/{id}/analyze` | (Pro/Team) Trigger detailed two-phase analysis before generation. | 2 |
|
||||
| `POST` | `/{id}/refine` | Send a refinement message scoped to this import's context. Returns updated nodes. | 2 |
|
||||
| `POST` | `/batch` | Submit multiple files. Returns `batch_id` + array of `kb_import_id`s. | 3 |
|
||||
| `GET` | `/batch/{batch_id}` | Get grouped batch status and per-import outcomes. | 3 |
|
||||
| `GET` | `/metrics` | KPI dashboard data: conversion rate, avg confidence, format usage, etc. | 3 |
|
||||
|
||||
### Upload Endpoint Detail
|
||||
|
||||
**`POST /api/v1/kb-accelerator/upload`**
|
||||
|
||||
Accepts `multipart/form-data` (file upload) or `application/json` (text paste).
|
||||
|
||||
**Request — File Upload:**
|
||||
- `file`: UploadFile (required) — the KB article file
|
||||
- `target_type`: string (optional) — `"troubleshooting"` or `"procedural"`. If omitted, AI decides.
|
||||
|
||||
**Request — Text Paste:**
|
||||
- `content`: string (required) — raw text content
|
||||
- `title`: string (optional) — suggested title
|
||||
- `target_type`: string (optional)
|
||||
|
||||
**Validation:**
|
||||
- Max file size: 10MB
|
||||
- Format whitelist: `.txt`, `.docx` (Phase 1); `.pdf`, `.html`, `.md` (Phase 2)
|
||||
- MIME type verification (content matches extension)
|
||||
- Tier format check against `kb_allowed_formats`
|
||||
- Lifetime conversion count check against `kb_max_lifetime_conversions`
|
||||
|
||||
**Response (201 Created):**
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"status": "processing",
|
||||
"source_format": "docx"
|
||||
}
|
||||
```
|
||||
|
||||
**Pipeline behavior:** After successful upload and extraction, the auto-convert pipeline triggers immediately. Frontend polls `GET /{id}` until status changes from `processing` to `ready` (or `failed`).
|
||||
|
||||
### Node Edit Endpoint Detail
|
||||
|
||||
**`PATCH /api/v1/kb-accelerator/{id}/nodes/{node_id}`**
|
||||
|
||||
Supports a union of operations:
|
||||
|
||||
- **`approve`**: Sets `user_approved = true`
|
||||
- **`reject`**: Sets `user_approved = false`
|
||||
- **`edit`**: Updates `content` JSONB, sets `user_edited = true`
|
||||
- **`delete`**: Removes the node, reorders remaining nodes
|
||||
- **`regenerate`**: Re-runs AI generation for this single node with optional user guidance text. Uses shared AI service.
|
||||
- **`insert_after`**: Creates a new node after this one, shifts `node_order` for subsequent nodes
|
||||
|
||||
### Commit Endpoint Detail
|
||||
|
||||
**`POST /api/v1/kb-accelerator/{id}/commit`**
|
||||
|
||||
1. Validate all nodes are reviewed (or allow commit with unreviewed nodes — user's choice)
|
||||
2. Build `tree_structure` JSONB from `kb_import_nodes` rows
|
||||
3. Create Tree record with appropriate `tree_type` (`troubleshooting` or `procedural`)
|
||||
4. For procedural flows: include generated `intake_form` schema from detected variables
|
||||
5. Set `import_metadata` JSONB with provenance data
|
||||
6. Update `kb_import.status` to `committed`, set `kb_import.tree_id`
|
||||
7. Run best-effort RAG indexing on the new tree
|
||||
8. Record audit event
|
||||
|
||||
**Batch behavior (Phase 3):** Successful batch items auto-commit as draft trees. Failed items retain `failed` status with error details.
|
||||
|
||||
---
|
||||
|
||||
## 5. AI Pipeline
|
||||
|
||||
### Single-Phase Conversion (Default)
|
||||
|
||||
One AI call that takes extracted text and returns a complete tree structure.
|
||||
|
||||
**System Prompt establishes:**
|
||||
- AI role as MSP documentation specialist
|
||||
- Target flow type (troubleshooting or procedural)
|
||||
- ResolutionFlow tree schema with examples (reuse patterns from `ai_chat_service.py`)
|
||||
- Confidence scoring instructions (0.0–1.0 per node with criteria)
|
||||
- Source excerpt attribution requirement (every node must cite its source text)
|
||||
- Variable detection instructions for procedural flows (`[VAR:name]` tokens)
|
||||
|
||||
**User message contains:**
|
||||
- Extracted text with structural metadata (headings, lists, emphasis markers)
|
||||
- Source filename and format for context
|
||||
|
||||
**Expected response:** Strict JSON matching the structure needed to populate `kb_import_nodes` rows, including `node_type`, `content`, `confidence_score`, `source_excerpt`, and parent-child relationships.
|
||||
|
||||
**Model routing:** Add `kb_convert` to `ACTION_MODEL_MAP` → maps to Sonnet (standard tier).
|
||||
|
||||
**Token tracking:** Record `ai_tokens_input` and `ai_tokens_output` on the `kb_import` record. Also call `record_ai_usage` for quota/cost tracking through the shared service.
|
||||
|
||||
### Two-Phase Analysis + Generation (Optional, Pro/Team)
|
||||
|
||||
**Phase 1 — Analysis:** AI returns structured JSON of detected elements (document type, problem statement, prerequisites, sequential steps, decision points, variables, warnings, resolutions, verification steps). Stored in `kb_import.source_metadata` or a dedicated analysis column.
|
||||
|
||||
**Phase 2 — Generation:** Takes Phase 1 analysis + original text → generates tree structure (same output as single-phase).
|
||||
|
||||
**Model routing:** Add `kb_analyze` to `ACTION_MODEL_MAP`.
|
||||
|
||||
### Confidence Scoring
|
||||
|
||||
| Score Range | Label | UI Indicator |
|
||||
|---|---|---|
|
||||
| 0.9 – 1.0 | High Confidence | Green left accent border |
|
||||
| 0.7 – 0.89 | Medium Confidence | Amber left accent border |
|
||||
| 0.5 – 0.69 | Low Confidence | Red left accent border |
|
||||
| < 0.5 | Needs Review | Red left accent border + flag icon |
|
||||
|
||||
### Procedural Flow: Variable Detection
|
||||
|
||||
For procedural target type, the AI identifies instance-specific values and maps them to `[VAR:name]` tokens:
|
||||
|
||||
| Pattern | Variable Name | Form Field Type |
|
||||
|---|---|---|
|
||||
| IP addresses | `[VAR:ip_address]` | ip_address |
|
||||
| Server/computer names | `[VAR:server_name]` | text |
|
||||
| Domain names | `[VAR:domain_name]` | text |
|
||||
| Usernames/email | `[VAR:username]` | text |
|
||||
| License types | `[VAR:license_type]` | select |
|
||||
| OU paths | `[VAR:ou_path]` | text |
|
||||
| Port numbers | `[VAR:port]` | number |
|
||||
| Subnet masks | `[VAR:subnet_mask]` | ip_address |
|
||||
|
||||
An `intake_form` JSONB schema is auto-generated from detected variables and stored on the committed tree.
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend Design
|
||||
|
||||
### Route: `/kb-accelerator`
|
||||
|
||||
Multi-step wizard with 3-4 screens, all within the existing app shell (sidebar + topbar). Uses the current design system: dark theme, cyan brand color, glass morphism, IBM Plex Sans / Bricolage Grotesque / JetBrains Mono fonts.
|
||||
|
||||
### Screen 1: Upload
|
||||
|
||||
- Drag-and-drop zone for files with format badges (DOCX, TXT in Phase 1)
|
||||
- Tab switch to "Paste Text" with full-width textarea + title field
|
||||
- Target type selector: two visual cards (Troubleshooting Flow / Procedure Flow) + "Let AI decide" option
|
||||
- Primary action: "Convert" button (`bg-gradient-brand`)
|
||||
- Pro/Team users see additional "Detailed Analysis" button alongside "Convert"
|
||||
- Container: `.glass-card-static`
|
||||
- Tier gating: free users see format restrictions and remaining conversion count
|
||||
|
||||
### Screen 2: Analysis Preview (Phase 2, Pro/Team Only, Feature-Flagged)
|
||||
|
||||
- Shows detected elements as color-coded cards: steps (blue), decision points (amber), warnings (red), variables (green), resolutions (emerald)
|
||||
- Source text excerpts linked to each detection
|
||||
- "Proceed to Generation" and "Re-analyze" action buttons
|
||||
- Only accessible when user clicks "Detailed Analysis" on the upload screen
|
||||
|
||||
### Screen 3: Review (Core Experience)
|
||||
|
||||
**Two-Panel Side-by-Side Layout:**
|
||||
|
||||
- **Left panel:** Original document text with detected elements highlighted inline (color-matched to generated nodes)
|
||||
- **Right panel:** Generated flow preview — tree visualization for troubleshooting, step list for procedures
|
||||
- Clicking a node in the right panel highlights its source excerpt in the left panel, and vice versa
|
||||
- Each node shows confidence score via left accent border pattern (green/amber/red)
|
||||
|
||||
**Per-Node Actions:**
|
||||
|
||||
- **Approve** (checkmark): Sets `user_approved = true`
|
||||
- **Edit** (pencil): Opens inline editing for content, question text, options
|
||||
- **Regenerate** (refresh): Re-runs AI for just this node with optional guidance
|
||||
- **Delete** (trash): Removes node from generated flow
|
||||
- **Add Node** (plus): Insert a manual node after this one
|
||||
|
||||
**Bulk Actions:**
|
||||
|
||||
- "Approve All High Confidence" — one-click approval for all nodes scoring ≥ 0.9
|
||||
- "Commit to Library" — finalizes the flow
|
||||
|
||||
**AI Refinement Panel (Phase 2):** Slide-in panel on the review screen for conversational refinement. User types natural language instructions ("Add a warning about DNS propagation after step 4", "Split this decision point"). Scoped to the KB import context — NOT the general FlowPilot chat. Reuses `EditorAIPanel` component internals with KB-specific branding.
|
||||
|
||||
**Step Library Suggestions (Phase 2):** For procedural flows, matched steps show a "Link to Library" badge. Clicking shows the library step content and lets the user swap the generated step for the library step.
|
||||
|
||||
### Screen 4: Success
|
||||
|
||||
- Confirmation with link to the new flow in the editor
|
||||
- "Convert Another" button
|
||||
- Stats: average confidence score, node count, processing time
|
||||
|
||||
### Navigation
|
||||
|
||||
- **Sidebar:** "KB Accelerator" nav item with sparkle/lightning icon
|
||||
- **Flow library header:** "Import KB Article" button next to "Create New Flow"
|
||||
|
||||
---
|
||||
|
||||
## 7. Tier Gating
|
||||
|
||||
| Capability | Free | Pro ($19/mo) | Team ($15/user/mo) |
|
||||
|---|---|---|---|
|
||||
| **Conversions** | 3 lifetime (account-wide) | Unlimited | Unlimited |
|
||||
| **Formats** | TXT + paste only | All formats | All formats |
|
||||
| **Target type selection** | AI decides only | Manual + AI | Manual + AI |
|
||||
| **Detailed analysis** | No | Yes | Yes |
|
||||
| **Conversational refinement** | No | Yes (Phase 2) | Yes (Phase 2) |
|
||||
| **Step Library matching** | No | Yes (Phase 2) | Yes (Phase 2) |
|
||||
| **Review actions** | Approve / Edit / Delete | Full (+ regenerate, insert, bulk approve) | Full (+ regenerate, insert, bulk approve) |
|
||||
| **Import history** | Last 3 only | Full history | Full history + audit log |
|
||||
| **Batch import** | No | Up to 5 articles (Phase 3) | Up to 10 articles (Phase 3) |
|
||||
| **Team visibility** | N/A | N/A | Shared read access to imports |
|
||||
|
||||
**Enforcement:** Hard server-side checks on every endpoint. Check `subscription.plan` → `PlanLimits` columns. Free tier lifetime count = `COUNT(*) FROM kb_imports WHERE account_id = ? AND status = 'committed'`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Build Phases
|
||||
|
||||
### Phase 1: Core Pipeline (Target: 2–3 Weeks)
|
||||
|
||||
The goal is a complete, working single-article conversion loop for text, paste, and DOCX inputs producing both troubleshooting and procedural flows.
|
||||
|
||||
**Backend:**
|
||||
|
||||
- Migration 054: `kb_imports` and `kb_import_nodes` tables
|
||||
- Migration 055: `PlanLimits` KB Accelerator columns + seed defaults
|
||||
- Upload endpoint — text, paste, DOCX extraction (python-docx)
|
||||
- Single-phase AI conversion — prompt engineering, structured JSON parsing, node creation
|
||||
- Node edit endpoint — approve, reject, edit, delete, regenerate, insert_after
|
||||
- Commit endpoint — create Tree, set `import_metadata`, strip staging data, RAG indexing
|
||||
- List/get import endpoints with pagination and status filter
|
||||
- Quota endpoint — return plan entitlements and usage counts
|
||||
- Delete/cancel endpoint
|
||||
- Hard tier gating — format checks, lifetime conversion count, review action restrictions
|
||||
- Add `kb_convert` to `ACTION_MODEL_MAP`
|
||||
- Extraction service module (TXT, paste, DOCX) with pluggable architecture for Phase 2 formats
|
||||
- Upload validation service — extension, MIME, size, pluggable scan hook (no-op default)
|
||||
|
||||
**Frontend:**
|
||||
|
||||
- Upload screen — drag-drop zone, paste tab, target type cards, "Let AI decide"
|
||||
- Review screen — two-panel layout, confidence indicators, per-node actions, source highlighting
|
||||
- Success screen — confirmation, stats, "Convert Another"
|
||||
- Sidebar nav item + flow library CTA button
|
||||
- KB Accelerator API client module (`kbAccelerator.ts`)
|
||||
- TypeScript types (`kbAccelerator.ts`)
|
||||
- HTTP polling for processing status
|
||||
- Tier gating UI — format restrictions shown, remaining conversions shown, upgrade prompts for locked features
|
||||
|
||||
**Both flow types** (troubleshooting + procedural) supported from Phase 1 start.
|
||||
|
||||
### Phase 2: Rich Formats & Refinement (Target: 2–3 Weeks)
|
||||
|
||||
Layer on additional formats, the power-user analysis preview, conversational refinement, and Step Library matching.
|
||||
|
||||
**Backend:**
|
||||
|
||||
- PDF extraction via PyMuPDF with extraction preview/correction endpoint (user verifies extracted text before AI processing)
|
||||
- HTML extraction via BeautifulSoup
|
||||
- Markdown extraction via markdown-it-py
|
||||
- Detailed analysis endpoint — two-phase AI (analyze → generate), Pro/Team gated
|
||||
- Conversational refinement endpoint — scoped chat for the KB import context, uses shared AI service, NOT `AIChatSession`
|
||||
- Step Library matching service — compare generated procedural steps against user's Step Library (text similarity or pgvector embeddings)
|
||||
- Add `kb_analyze` and `kb_refine` to `ACTION_MODEL_MAP`
|
||||
|
||||
**Frontend:**
|
||||
|
||||
- PDF extraction preview screen — shows extracted text, highlights potential issues, user can edit before AI processing
|
||||
- Analysis preview screen — feature-flagged for Pro/Team, shows detected elements as color-coded cards
|
||||
- AI refinement slide-in panel on review screen — reuses `EditorAIPanel` internals with KB branding
|
||||
- Step Library match suggestions — "Link to Library" badges on matched procedural steps
|
||||
- "Approve All High Confidence" bulk action button
|
||||
|
||||
### Phase 3: Scale & Polish (Future)
|
||||
|
||||
Batch import, history dashboard, and analytics.
|
||||
|
||||
**Backend:**
|
||||
|
||||
- Batch upload endpoint — multipart `files[]` + shared options, returns `batch_id` + import IDs
|
||||
- Batch status endpoint
|
||||
- FastAPI background jobs for batch processing (DB-based job queue)
|
||||
- Auto-commit as draft for successful batch items
|
||||
- Import history dashboard endpoint
|
||||
- Metrics/analytics endpoint — conversion rate, avg confidence, format usage, time trends
|
||||
|
||||
**Frontend:**
|
||||
|
||||
- Batch upload UI — multi-file drag-drop with per-file status indicators
|
||||
- Batch results view — shows auto-committed drafts and failed items
|
||||
- Import history dashboard with filters and search
|
||||
- Analytics visualizations (conversion trends, confidence distributions)
|
||||
|
||||
---
|
||||
|
||||
## 9. Files to Create and Modify
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `backend/alembic/versions/054_add_kb_imports.py` | Migration: `kb_imports` + `kb_import_nodes` tables |
|
||||
| `backend/alembic/versions/055_add_kb_plan_limits.py` | Migration: PlanLimits KB columns + seed defaults |
|
||||
| `backend/app/models/kb_import.py` | SQLAlchemy models: `KBImport`, `KBImportNode` |
|
||||
| `backend/app/schemas/kb_accelerator.py` | Pydantic schemas: request/response DTOs |
|
||||
| `backend/app/api/endpoints/kb_accelerator.py` | API endpoints |
|
||||
| `backend/app/core/kb_extraction_service.py` | Text extraction (TXT, paste, DOCX; extensible for Phase 2 formats) |
|
||||
| `backend/app/core/kb_conversion_service.py` | AI prompt orchestration, JSON parsing, node creation |
|
||||
| `backend/tests/test_kb_accelerator.py` | Integration tests |
|
||||
| `frontend/src/api/kbAccelerator.ts` | API client module |
|
||||
| `frontend/src/types/kbAccelerator.ts` | TypeScript types |
|
||||
| `frontend/src/pages/KBAcceleratorPage.tsx` | Main page (multi-step wizard) |
|
||||
| `frontend/src/components/kb-accelerator/UploadScreen.tsx` | Upload UI component |
|
||||
| `frontend/src/components/kb-accelerator/ReviewScreen.tsx` | Two-panel review UI component |
|
||||
| `frontend/src/components/kb-accelerator/SuccessScreen.tsx` | Post-commit confirmation component |
|
||||
| `frontend/src/components/kb-accelerator/NodeCard.tsx` | Individual node display with confidence + actions |
|
||||
| `frontend/src/components/kb-accelerator/SourcePanel.tsx` | Left panel: source text with highlights |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `backend/app/models/__init__.py` | Import `KBImport`, `KBImportNode` |
|
||||
| `backend/alembic/env.py` | Import KB models for migration detection |
|
||||
| `backend/app/api/router.py` | Register `kb_accelerator` router |
|
||||
| `backend/app/core/config.py` | Add `kb_convert` (Phase 1), `kb_analyze`, `kb_refine` (Phase 2) to `ACTION_MODEL_MAP` |
|
||||
| `backend/app/models/plan_limits.py` | Add KB Accelerator limit columns |
|
||||
| `frontend/src/router.tsx` | Add `/kb-accelerator` route |
|
||||
| `frontend/src/components/layout/AppLayout.tsx` or `Sidebar.tsx` | Add KB Accelerator sidebar nav item |
|
||||
| `frontend/src/types/index.ts` | Export KB Accelerator types |
|
||||
| `frontend/src/api/index.ts` | Export KB Accelerator API client |
|
||||
|
||||
### Existing Files Reused (Not Modified)
|
||||
|
||||
| File | What's Reused |
|
||||
|---|---|
|
||||
| `backend/app/core/ai_chat_service.py` | Prompt patterns, structured output parsing examples |
|
||||
| `backend/app/core/ai_quota_service.py` | `check_ai_quota()`, `record_ai_usage()` |
|
||||
| `backend/app/core/ai_provider_service.py` | `get_model_for_action()`, Anthropic client patterns |
|
||||
| `frontend/src/components/tree-editor/EditorAIPanel.tsx` | Component internals reused for refinement panel (Phase 2) |
|
||||
|
||||
---
|
||||
|
||||
## 10. Test Plan
|
||||
|
||||
### Backend Integration Tests
|
||||
|
||||
**Upload & Extraction:**
|
||||
- Upload text/paste → verify `kb_import` created with status `processing`
|
||||
- Upload DOCX → verify extraction produces `source_text` and `source_metadata`
|
||||
- Upload unsupported format → verify 400 rejection
|
||||
- Upload exceeding 10MB → verify 413 rejection
|
||||
- Upload DOCX on free tier → verify 403 (format not in plan)
|
||||
- Upload when lifetime limit reached → verify 403 with upgrade message
|
||||
|
||||
**AI Conversion:**
|
||||
- Convert troubleshooting article → verify `kb_import_nodes` created with correct types, confidence scores, source excerpts
|
||||
- Convert procedural article → verify step nodes created with `[VAR:name]` tokens and `intake_form` data
|
||||
- Convert with AI failure → verify status set to `failed` with error message
|
||||
- Verify token counts recorded on `kb_import`
|
||||
- Verify `record_ai_usage` called through shared service
|
||||
|
||||
**Node Review Actions:**
|
||||
- Approve node → verify `user_approved = true`
|
||||
- Edit node → verify `content` updated, `user_edited = true`
|
||||
- Delete node → verify removed, `node_order` resequenced
|
||||
- Regenerate node → verify AI called, node content replaced, new confidence score
|
||||
- Insert after → verify new node created with correct `node_order`, subsequent nodes shifted
|
||||
|
||||
**Commit:**
|
||||
- Commit troubleshooting import → verify Tree created with correct `tree_type`, `tree_structure`, `import_metadata`
|
||||
- Commit procedural import → verify Tree created with `intake_form` populated
|
||||
- Verify `kb_import.status` = `committed`, `tree_id` set
|
||||
- Verify committed tree appears in flow library
|
||||
- Verify RAG indexing triggered (best-effort)
|
||||
|
||||
**Tier Enforcement:**
|
||||
- Free tier: 4th conversion rejected (account-scoped lifetime count)
|
||||
- Free tier: DOCX upload rejected, paste accepted
|
||||
- Pro tier: unlimited conversions, all formats accepted
|
||||
- Team tier: other account members can view import (shared visibility)
|
||||
|
||||
### Frontend Tests
|
||||
|
||||
- Upload flow: file drag-drop, paste, target type selection, validation messages
|
||||
- Polling: status transitions from `processing` to `ready`
|
||||
- Review screen: node display, confidence colors, source highlighting, click-to-highlight linking
|
||||
- Node actions: inline edit, approve, delete — optimistic UI updates
|
||||
- Commit flow: success screen, link to editor works
|
||||
- Tier gating: free tier sees upgrade prompts, format restrictions shown, conversion count displayed
|
||||
|
||||
### E2E Smoke Test
|
||||
|
||||
1. Paste a sample KB article text
|
||||
2. Select "Troubleshooting Flow"
|
||||
3. Click "Convert"
|
||||
4. Wait for processing → land on review screen
|
||||
5. Verify nodes displayed with confidence indicators
|
||||
6. Edit one low-confidence node
|
||||
7. Approve all high-confidence nodes
|
||||
8. Click "Commit to Library"
|
||||
9. Verify flow appears in library
|
||||
10. Open flow in tree editor — verify structure is correct
|
||||
|
||||
---
|
||||
|
||||
## 11. Success Metrics (Post-Launch)
|
||||
|
||||
| Metric | Target | Why It Matters |
|
||||
|---|---|---|
|
||||
| **Conversion completion rate** | > 70% | Imports reaching `committed` status. Below 70% = review too burdensome or quality too low. |
|
||||
| **Average confidence score** | > 0.75 | Across all generated nodes. Indicates AI pipeline accuracy. |
|
||||
| **Time from upload to commit** | < 10 minutes | Full cycle should feel fast. 30+ minutes = AI not saving enough time. |
|
||||
| **Free-to-Pro conversion rate** | > 15% | Users who exhaust 3 free conversions then upgrade. Validates feature drives revenue. |
|
||||
| **Repeat usage (Pro/Team)** | > 3 imports/month | Sustained usage indicates feature is part of workflow, not a one-time novelty. |
|
||||
| **Node edit rate** | < 30% | Percentage of nodes edited before commit. Lower = AI output more usable as-is. |
|
||||
| **Imported flow usage rate** | > 50% | Committed flows used in sessions within 30 days. Low = conversion producing shelfware. |
|
||||
|
||||
---
|
||||
|
||||
*End of Plan*
|
||||
|
||||
*ResolutionFlow LLC — March 2026*
|
||||
1639
docs/resolutionflow-landing-page-mockup.html
Normal file
1639
docs/resolutionflow-landing-page-mockup.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -20,3 +20,4 @@ export { default as aiBuilderApi } from './aiBuilder'
|
||||
export { copilotApi } from './copilot'
|
||||
export { assistantChatApi } from './assistantChat'
|
||||
export { flowTransferApi } from './flowTransfer'
|
||||
export { kbAcceleratorApi } from './kbAccelerator'
|
||||
|
||||
76
frontend/src/api/kbAccelerator.ts
Normal file
76
frontend/src/api/kbAccelerator.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import apiClient from './client'
|
||||
import type {
|
||||
KBUploadResponse,
|
||||
KBImport,
|
||||
KBImportListResponse,
|
||||
KBImportNode,
|
||||
KBCommitResponse,
|
||||
KBQuotaResponse,
|
||||
KBListParams,
|
||||
KBNodeEditRequest,
|
||||
KBCommitRequest,
|
||||
} from '@/types/kbAccelerator'
|
||||
|
||||
export const kbAcceleratorApi = {
|
||||
async uploadText(data: { content: string; title?: string; target_type?: string }): Promise<KBUploadResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('content', data.content)
|
||||
if (data.title) formData.append('title', data.title)
|
||||
if (data.target_type) formData.append('target_type', data.target_type)
|
||||
|
||||
const response = await apiClient.post<KBUploadResponse>('/kb-accelerator/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async uploadFile(file: File, targetType?: string): Promise<KBUploadResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (targetType) formData.append('target_type', targetType)
|
||||
|
||||
const response = await apiClient.post<KBUploadResponse>('/kb-accelerator/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async get(id: string): Promise<KBImport> {
|
||||
const response = await apiClient.get<KBImport>(`/kb-accelerator/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async list(params?: KBListParams): Promise<KBImportListResponse> {
|
||||
const response = await apiClient.get<KBImportListResponse>('/kb-accelerator', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async convert(id: string): Promise<{ status: string }> {
|
||||
const response = await apiClient.post<{ status: string }>(`/kb-accelerator/${id}/convert`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async editNode(importId: string, nodeId: string, data: KBNodeEditRequest): Promise<KBImportNode> {
|
||||
const response = await apiClient.patch<KBImportNode>(
|
||||
`/kb-accelerator/${importId}/nodes/${nodeId}`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async commit(id: string, data?: KBCommitRequest): Promise<KBCommitResponse> {
|
||||
const response = await apiClient.post<KBCommitResponse>(`/kb-accelerator/${id}/commit`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await apiClient.delete(`/kb-accelerator/${id}`)
|
||||
},
|
||||
|
||||
async getQuota(): Promise<KBQuotaResponse> {
|
||||
const response = await apiClient.get<KBQuotaResponse>('/kb-accelerator/quota')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default kbAcceleratorApi
|
||||
@@ -7,7 +7,29 @@ interface FallbackProps {
|
||||
resetError: () => void
|
||||
}
|
||||
|
||||
function isChunkLoadError(error: Error): boolean {
|
||||
const msg = error.message || ''
|
||||
return (
|
||||
msg.includes('dynamically imported module') ||
|
||||
msg.includes('Loading chunk') ||
|
||||
msg.includes('Failed to fetch') ||
|
||||
error.name === 'ChunkLoadError'
|
||||
)
|
||||
}
|
||||
|
||||
function DefaultFallback({ error, resetError }: FallbackProps) {
|
||||
// Auto-reload on stale chunk errors (happens after deployments)
|
||||
if (isChunkLoadError(error)) {
|
||||
const key = 'rf_boundary_chunk_reload'
|
||||
const lastReload = sessionStorage.getItem(key)
|
||||
const now = Date.now()
|
||||
if (!lastReload || now - Number(lastReload) > 10_000) {
|
||||
sessionStorage.setItem(key, String(now))
|
||||
window.location.reload()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center p-8">
|
||||
<div className="max-w-md text-center">
|
||||
|
||||
201
frontend/src/components/kb-accelerator/NodeCard.tsx
Normal file
201
frontend/src/components/kb-accelerator/NodeCard.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useState } from 'react'
|
||||
import { Check, X, Pencil, Trash2, RotateCcw, Plus, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { KBImportNode, KBNodeEditRequest } from '@/types/kbAccelerator'
|
||||
|
||||
interface NodeCardProps {
|
||||
node: KBImportNode
|
||||
onEdit: (nodeId: string, data: KBNodeEditRequest) => Promise<void>
|
||||
onHighlight: (excerpt: string | null) => void
|
||||
}
|
||||
|
||||
function confidenceColor(score: number): string {
|
||||
if (score >= 0.85) return 'border-emerald-400/40'
|
||||
if (score >= 0.65) return 'border-amber-400/40'
|
||||
return 'border-rose-500/40'
|
||||
}
|
||||
|
||||
function confidenceLabel(score: number): string {
|
||||
if (score >= 0.85) return 'High'
|
||||
if (score >= 0.65) return 'Medium'
|
||||
return 'Low'
|
||||
}
|
||||
|
||||
function confidenceTextColor(score: number): string {
|
||||
if (score >= 0.85) return 'text-emerald-400'
|
||||
if (score >= 0.65) return 'text-amber-400'
|
||||
return 'text-rose-500'
|
||||
}
|
||||
|
||||
export function NodeCard({ node, onEdit, onHighlight }: NodeCardProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [editContent, setEditContent] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const question = (node.content?.question as string) ?? ''
|
||||
const options = (node.content?.options as Array<{ label: string; next_node_id?: string }>) ?? []
|
||||
const stepContent = (node.content?.content as string) ?? question
|
||||
|
||||
const handleAction = async (operation: KBNodeEditRequest['operation'], extra?: Partial<KBNodeEditRequest>) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await onEdit(node.id, { operation, ...extra })
|
||||
if (operation === 'edit') setEditMode(false)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = () => {
|
||||
setEditContent(stepContent || question)
|
||||
setEditMode(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'glass-card-static border-l-4 p-4 transition-all',
|
||||
confidenceColor(node.confidence_score),
|
||||
node.user_approved && 'opacity-75',
|
||||
)}
|
||||
onMouseEnter={() => onHighlight(node.source_excerpt)}
|
||||
onMouseLeave={() => onHighlight(null)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
{node.node_type}
|
||||
</span>
|
||||
<span className={cn('font-label text-[0.625rem]', confidenceTextColor(node.confidence_score))}>
|
||||
{confidenceLabel(node.confidence_score)} ({Math.round(node.confidence_score * 100)}%)
|
||||
</span>
|
||||
{node.user_approved && (
|
||||
<span className="font-label text-[0.625rem] text-emerald-400">Approved</span>
|
||||
)}
|
||||
{node.user_edited && (
|
||||
<span className="font-label text-[0.625rem] text-blue-400">Edited</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editMode ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={e => setEditContent(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-primary/30 focus:outline-hidden"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleAction('edit', { content: { ...node.content, question: editContent, content: editContent } })}
|
||||
disabled={busy}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-md bg-gradient-brand text-[#101114] hover:opacity-90"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditMode(false)}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-md bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-foreground">{stepContent || question}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!editMode && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{!node.user_approved && (
|
||||
<button
|
||||
onClick={() => handleAction('approve')}
|
||||
disabled={busy}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-emerald-400 hover:bg-emerald-400/10 transition-colors"
|
||||
title="Approve"
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
)}
|
||||
{node.user_approved && (
|
||||
<button
|
||||
onClick={() => handleAction('reject')}
|
||||
disabled={busy}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-amber-400 hover:bg-amber-400/10 transition-colors"
|
||||
title="Unapprove"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={startEdit}
|
||||
disabled={busy}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-blue-400 hover:bg-blue-400/10 transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction('regenerate')}
|
||||
disabled={busy}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
title="Regenerate"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction('insert_after', { content: { question: 'New node', type: node.node_type } })}
|
||||
disabled={busy}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
title="Insert after"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction('delete')}
|
||||
disabled={busy}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options (troubleshooting) */}
|
||||
{options.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{options.length} option{options.length !== 1 ? 's' : ''}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-2 space-y-1 pl-3 border-l border-border">
|
||||
{options.map((opt, i) => (
|
||||
<p key={i} className="text-xs text-muted-foreground">
|
||||
{opt.label} {opt.next_node_id && <span className="text-[#5a6170]">→ {opt.next_node_id}</span>}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source excerpt */}
|
||||
{node.source_excerpt && (
|
||||
<p className="mt-2 text-xs text-[#5a6170] italic truncate" title={node.source_excerpt}>
|
||||
Source: {node.source_excerpt}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
146
frontend/src/components/kb-accelerator/ReviewScreen.tsx
Normal file
146
frontend/src/components/kb-accelerator/ReviewScreen.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle2, AlertTriangle, BarChart3, CheckCheck } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { NodeCard } from './NodeCard'
|
||||
import { SourcePanel } from './SourcePanel'
|
||||
import type { KBImport, KBNodeEditRequest, KBCommitRequest } from '@/types/kbAccelerator'
|
||||
|
||||
interface ReviewScreenProps {
|
||||
kbImport: KBImport
|
||||
onEditNode: (nodeId: string, data: KBNodeEditRequest) => Promise<void>
|
||||
onApproveAll: () => Promise<void>
|
||||
onCommit: (data?: KBCommitRequest) => Promise<void>
|
||||
onDiscard: () => Promise<void>
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export function ReviewScreen({ kbImport, onEditNode, onApproveAll, onCommit, onDiscard, loading }: ReviewScreenProps) {
|
||||
const [highlightExcerpt, setHighlightExcerpt] = useState<string | null>(null)
|
||||
|
||||
const nodes = kbImport.nodes
|
||||
const approvedCount = nodes.filter(n => n.user_approved).length
|
||||
const lowConfidenceCount = nodes.filter(n => n.confidence_score < 0.65).length
|
||||
const avgConfidence = kbImport.confidence_avg ?? 0
|
||||
|
||||
const title = (kbImport.source_metadata as Record<string, unknown> | null)
|
||||
?._conversion as Record<string, unknown> | undefined
|
||||
const flowTitle = (title?.title as string) ?? 'Untitled Flow'
|
||||
const flowDescription = (title?.description as string) ?? ''
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-4">
|
||||
{/* Header */}
|
||||
<div className="glass-card-static p-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-heading font-semibold text-foreground">{flowTitle}</h2>
|
||||
{flowDescription && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{flowDescription}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<BarChart3 size={14} className="text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
Avg confidence: <span className="text-foreground font-medium">{Math.round(avgConfidence * 100)}%</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 size={14} className="text-emerald-400" />
|
||||
<span className="text-muted-foreground">
|
||||
{approvedCount}/{nodes.length} approved
|
||||
</span>
|
||||
</div>
|
||||
{lowConfidenceCount > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle size={14} className="text-amber-400" />
|
||||
<span className="text-amber-400">
|
||||
{lowConfidenceCount} low confidence
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{approvedCount < nodes.length && (
|
||||
<button
|
||||
onClick={onApproveAll}
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-[8px] text-xs font-medium transition-colors',
|
||||
'bg-emerald-400/10 border border-emerald-400/20 text-emerald-400',
|
||||
'hover:bg-emerald-400/20 hover:border-emerald-400/30',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<CheckCheck size={14} />
|
||||
Approve All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-panel layout */}
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4 min-h-0">
|
||||
{/* Source panel */}
|
||||
<div className="overflow-hidden">
|
||||
<SourcePanel
|
||||
sourceText={kbImport.source_text}
|
||||
sourceFormat={kbImport.source_format}
|
||||
highlightExcerpt={highlightExcerpt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Nodes panel */}
|
||||
<div className="flex flex-col glass-card-static overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<span className="text-sm font-medium text-foreground">Generated Flow</span>
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground ml-auto">
|
||||
{kbImport.target_type === 'troubleshooting' ? 'Troubleshooting' : 'Project'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{nodes.map(node => (
|
||||
<NodeCard
|
||||
key={node.id}
|
||||
node={node}
|
||||
onEdit={onEditNode}
|
||||
onHighlight={setHighlightExcerpt}
|
||||
/>
|
||||
))}
|
||||
{nodes.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No nodes generated. Try converting again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="shrink-0 flex items-center justify-between gap-3 pb-1">
|
||||
<button
|
||||
onClick={onDiscard}
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
'px-4 py-2.5 rounded-[10px] text-sm font-medium transition-colors',
|
||||
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-muted-foreground',
|
||||
'hover:text-foreground hover:border-[rgba(255,255,255,0.12)]',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onCommit()}
|
||||
disabled={loading || nodes.length === 0}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-6 py-2.5 rounded-[10px] text-sm font-semibold transition-all',
|
||||
'bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90 active:scale-[0.97]',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<CheckCircle2 size={16} />
|
||||
{loading ? 'Committing...' : 'Commit to Library'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
frontend/src/components/kb-accelerator/SourcePanel.tsx
Normal file
42
frontend/src/components/kb-accelerator/SourcePanel.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useMemo } from 'react'
|
||||
import { FileText } from 'lucide-react'
|
||||
|
||||
interface SourcePanelProps {
|
||||
sourceText: string
|
||||
sourceFormat: string
|
||||
highlightExcerpt: string | null
|
||||
}
|
||||
|
||||
export function SourcePanel({ sourceText, sourceFormat, highlightExcerpt }: SourcePanelProps) {
|
||||
const renderedText = useMemo(() => {
|
||||
if (!highlightExcerpt || !sourceText.includes(highlightExcerpt)) {
|
||||
return <span>{sourceText}</span>
|
||||
}
|
||||
|
||||
const idx = sourceText.indexOf(highlightExcerpt)
|
||||
return (
|
||||
<>
|
||||
<span>{sourceText.slice(0, idx)}</span>
|
||||
<mark className="bg-primary/20 text-foreground rounded px-0.5">{highlightExcerpt}</mark>
|
||||
<span>{sourceText.slice(idx + highlightExcerpt.length)}</span>
|
||||
</>
|
||||
)
|
||||
}, [sourceText, highlightExcerpt])
|
||||
|
||||
return (
|
||||
<div className="glass-card-static flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<FileText size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">Source Document</span>
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground ml-auto">
|
||||
{sourceFormat}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<pre className="text-sm text-muted-foreground whitespace-pre-wrap font-sans leading-relaxed">
|
||||
{renderedText}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
frontend/src/components/kb-accelerator/SuccessScreen.tsx
Normal file
56
frontend/src/components/kb-accelerator/SuccessScreen.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { CheckCircle2, ArrowRight, Plus } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { KBCommitResponse } from '@/types/kbAccelerator'
|
||||
|
||||
interface SuccessScreenProps {
|
||||
result: KBCommitResponse
|
||||
onViewFlow: () => void
|
||||
onConvertAnother: () => void
|
||||
}
|
||||
|
||||
export function SuccessScreen({ result, onViewFlow, onConvertAnother }: SuccessScreenProps) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto text-center space-y-6 py-12">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-16 h-16 rounded-full bg-emerald-400/10 flex items-center justify-center">
|
||||
<CheckCircle2 size={32} className="text-emerald-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-heading font-semibold text-foreground">
|
||||
Flow Created Successfully
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Your KB article has been converted into a {result.tree_type === 'troubleshooting' ? 'troubleshooting' : 'project'} flow
|
||||
and added to your library.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={onViewFlow}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-6 py-2.5 rounded-[10px] text-sm font-semibold transition-all',
|
||||
'bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90 active:scale-[0.97]'
|
||||
)}
|
||||
>
|
||||
View Flow
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onConvertAnother}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-6 py-2.5 rounded-[10px] text-sm font-medium transition-colors',
|
||||
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
|
||||
'hover:border-[rgba(255,255,255,0.12)]'
|
||||
)}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Convert Another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
264
frontend/src/components/kb-accelerator/UploadScreen.tsx
Normal file
264
frontend/src/components/kb-accelerator/UploadScreen.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { Upload, FileText, ClipboardPaste, FileUp, Sparkles, AlertCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import type { KBQuotaResponse } from '@/types/kbAccelerator'
|
||||
|
||||
type TargetType = 'troubleshooting' | 'procedural'
|
||||
|
||||
interface UploadScreenProps {
|
||||
quota: KBQuotaResponse | null
|
||||
onSubmitText: (content: string, title: string, targetType: TargetType) => void
|
||||
onSubmitFile: (file: File, targetType: TargetType) => void
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const TARGET_TYPES = [
|
||||
{
|
||||
value: 'troubleshooting' as const,
|
||||
label: 'Troubleshooting Flow',
|
||||
description: 'Decision tree with diagnostic questions and resolutions',
|
||||
},
|
||||
{
|
||||
value: 'procedural' as const,
|
||||
label: 'Project Flow',
|
||||
description: 'Step-by-step procedure with warnings and variables',
|
||||
},
|
||||
]
|
||||
|
||||
const FORMAT_LABELS: Record<string, string> = {
|
||||
txt: 'TXT',
|
||||
paste: 'Paste',
|
||||
docx: 'DOCX',
|
||||
pdf: 'PDF',
|
||||
html: 'HTML',
|
||||
md: 'Markdown',
|
||||
}
|
||||
|
||||
export function UploadScreen({ quota, onSubmitText, onSubmitFile, loading }: UploadScreenProps) {
|
||||
const [mode, setMode] = useState<'paste' | 'file'>('paste')
|
||||
const [content, setContent] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [targetType, setTargetType] = useState<TargetType>('troubleshooting')
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const canSubmit = mode === 'paste'
|
||||
? content.trim().length >= 10
|
||||
: file !== null
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (loading) return
|
||||
if (mode === 'paste') {
|
||||
onSubmitText(content, title, targetType)
|
||||
} else if (file) {
|
||||
onSubmitFile(file, targetType)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const droppedFile = e.dataTransfer.files[0]
|
||||
if (droppedFile) {
|
||||
setFile(droppedFile)
|
||||
setMode('file')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = e.target.files?.[0]
|
||||
if (selected) {
|
||||
setFile(selected)
|
||||
}
|
||||
}
|
||||
|
||||
const allowedFormats = quota?.allowed_formats ?? ['txt', 'paste']
|
||||
const fileFormats = allowedFormats.filter(f => f !== 'paste')
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
{/* Quota info */}
|
||||
{quota && (
|
||||
<div className="glass-card-static p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sparkles size={18} className="text-primary" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{quota.lifetime_conversions_limit
|
||||
? `${quota.lifetime_conversions_limit - quota.lifetime_conversions_used} conversions remaining`
|
||||
: 'Unlimited conversions'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{quota.plan.charAt(0).toUpperCase() + quota.plan.slice(1)} plan
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!quota.can_convert && (
|
||||
<div className="flex items-center gap-2 text-amber-400 text-sm">
|
||||
<AlertCircle size={16} />
|
||||
<span>Conversion limit reached</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setMode('paste')}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-[10px] text-sm font-medium transition-colors',
|
||||
mode === 'paste'
|
||||
? 'bg-primary/10 text-foreground border border-primary/30'
|
||||
: 'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.12)]'
|
||||
)}
|
||||
>
|
||||
<ClipboardPaste size={16} />
|
||||
Paste Text
|
||||
</button>
|
||||
{fileFormats.length > 0 && (
|
||||
<button
|
||||
onClick={() => setMode('file')}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-[10px] text-sm font-medium transition-colors',
|
||||
mode === 'file'
|
||||
? 'bg-primary/10 text-foreground border border-primary/30'
|
||||
: 'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.12)]'
|
||||
)}
|
||||
>
|
||||
<FileUp size={16} />
|
||||
Upload File
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="glass-card-static p-5 space-y-4">
|
||||
{mode === 'paste' ? (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="kb-title" className="block font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1.5">
|
||||
Title (optional)
|
||||
</label>
|
||||
<Input
|
||||
id="kb-title"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="e.g., Outlook Connectivity Troubleshooting"
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="kb-content" className="block font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1.5">
|
||||
KB Article Content
|
||||
</label>
|
||||
<Textarea
|
||||
id="kb-content"
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
placeholder="Paste your KB article text here..."
|
||||
rows={12}
|
||||
maxLength={500000}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{content.length.toLocaleString()} / 500,000 characters
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-3 p-10 rounded-xl border-2 border-dashed cursor-pointer transition-colors',
|
||||
dragOver
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'border-[rgba(255,255,255,0.08)] hover:border-[rgba(255,255,255,0.15)]'
|
||||
)}
|
||||
>
|
||||
{file ? (
|
||||
<>
|
||||
<FileText size={32} className="text-primary" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-foreground">{file.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{(file.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setFile(null) }}
|
||||
className="mt-2 text-xs text-primary hover:underline"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload size={32} className="text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-foreground">
|
||||
Drop a file here or <span className="text-primary">browse</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Supported: {fileFormats.map(f => FORMAT_LABELS[f] || f.toUpperCase()).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept={fileFormats.map(f => `.${f}`).join(',')}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Target type selector */}
|
||||
<div>
|
||||
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-2">
|
||||
Target Flow Type
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{TARGET_TYPES.map(t => (
|
||||
<button
|
||||
key={t.value}
|
||||
onClick={() => setTargetType(t.value)}
|
||||
className={cn(
|
||||
'glass-card-static p-4 text-left transition-all',
|
||||
targetType === t.value
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'hover:border-[rgba(255,255,255,0.12)]'
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium text-foreground">{t.label}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit || loading || (quota != null && !quota.can_convert)}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-center gap-2 px-6 py-3 rounded-[10px] text-sm font-semibold transition-all',
|
||||
'bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90 active:scale-[0.97]',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
{loading ? 'Converting...' : 'Convert to Flow'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />
|
||||
return <Navigate to="/landing" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
// Enforce must_change_password — redirect unless already on /change-password
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, BotMessageSquare, BookOpen } from 'lucide-react'
|
||||
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, BotMessageSquare, BookOpen, Sparkles } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
||||
@@ -83,6 +83,7 @@ export function Sidebar() {
|
||||
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
|
||||
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" collapsed />
|
||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
|
||||
<NavItem href="/kb-accelerator" icon={Sparkles} label="KB Accelerator" collapsed />
|
||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
|
||||
<NavItem href="/guides" icon={BookOpen} label="User Guides" collapsed />
|
||||
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" collapsed />
|
||||
@@ -115,6 +116,7 @@ export function Sidebar() {
|
||||
<NavItem href="/shares" icon={FileText} label="Exports" />
|
||||
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" />
|
||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" />
|
||||
<NavItem href="/kb-accelerator" icon={Sparkles} label="KB Accelerator" />
|
||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -35,10 +35,11 @@ interface FlowCanvasProps {
|
||||
selectedNodeId: string | null
|
||||
onNodeSelect: (nodeId: string | null) => void
|
||||
onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
||||
onNodeDelete?: (nodeId: string) => void
|
||||
onNodeContextMenu?: (e: React.MouseEvent, nodeId: string) => void
|
||||
}
|
||||
|
||||
function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onNodeContextMenu }: FlowCanvasProps) {
|
||||
function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onNodeDelete, onNodeContextMenu }: FlowCanvasProps) {
|
||||
const { fitView, setCenter } = useReactFlow()
|
||||
const { nodes: layoutNodes, edges: layoutEdges, collapsedNodeIds, toggleCollapse, onNodesMeasured } = useTreeLayout(selectedNodeId)
|
||||
const [minimapVisible, setMinimapVisible] = useState(true)
|
||||
@@ -51,7 +52,7 @@ function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onN
|
||||
return {
|
||||
...n,
|
||||
selected: n.id === selectedNodeId,
|
||||
data: { ...data, onToggleCollapse: toggleCollapse, onContextMenu: onNodeContextMenu },
|
||||
data: { ...data, onToggleCollapse: toggleCollapse, onDelete: onNodeDelete, onContextMenu: onNodeContextMenu },
|
||||
}
|
||||
}
|
||||
if (n.type === 'answerStub') {
|
||||
@@ -64,7 +65,7 @@ function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onN
|
||||
}
|
||||
return n
|
||||
})
|
||||
}, [layoutNodes, selectedNodeId, toggleCollapse, onSelectAnswerType, onNodeContextMenu])
|
||||
}, [layoutNodes, selectedNodeId, toggleCollapse, onSelectAnswerType, onNodeDelete, onNodeContextMenu])
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithCallbacks)
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutEdges)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo } from 'react'
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react'
|
||||
import { HelpCircle, Zap, CheckCircle, ChevronDown, ChevronRight, AlertCircle } from 'lucide-react'
|
||||
import { HelpCircle, Zap, CheckCircle, ChevronDown, ChevronRight, AlertCircle, Trash2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TreeStructure, NodeType } from '@/types'
|
||||
|
||||
@@ -40,14 +40,16 @@ export interface FlowCanvasNodeData {
|
||||
isCollapsed: boolean
|
||||
hasValidationErrors: boolean
|
||||
isNew: boolean
|
||||
isRoot: boolean
|
||||
onToggleCollapse: (nodeId: string) => void
|
||||
onDelete?: (nodeId: string) => void
|
||||
onContextMenu?: (e: React.MouseEvent, nodeId: string) => void
|
||||
onAcceptSuggestion?: (nodeId: string) => void
|
||||
onDismissSuggestion?: (nodeId: string) => void
|
||||
}
|
||||
|
||||
function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
|
||||
const { node, hasChildren, isCollapsed, hasValidationErrors, isNew, onToggleCollapse, onContextMenu, onAcceptSuggestion, onDismissSuggestion } = data as unknown as FlowCanvasNodeData
|
||||
const { node, hasChildren, isCollapsed, hasValidationErrors, isNew, isRoot, onToggleCollapse, onDelete, onContextMenu, onAcceptSuggestion, onDismissSuggestion } = data as unknown as FlowCanvasNodeData
|
||||
const isGhost = !!(node as unknown as Record<string, unknown>)._suggestion
|
||||
const nodeType = node.type as Exclude<NodeType, 'answer'>
|
||||
const config = NODE_TYPE_CONFIG[nodeType] ?? NODE_TYPE_CONFIG.decision
|
||||
@@ -67,7 +69,7 @@ function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
|
||||
<div
|
||||
onContextMenu={(e) => onContextMenu?.(e, node.id)}
|
||||
className={cn(
|
||||
'w-[280px] rounded-xl border border-border bg-card shadow-xs cursor-pointer transition-all',
|
||||
'group w-[280px] rounded-xl border border-border bg-card shadow-xs cursor-pointer transition-all',
|
||||
config.borderClass,
|
||||
selected && 'ring-1 ring-primary shadow-md',
|
||||
isGhost && 'border-dashed border-primary/40! opacity-60'
|
||||
@@ -94,6 +96,21 @@ function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
|
||||
{hasValidationErrors && (
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-400" />
|
||||
)}
|
||||
|
||||
{/* Delete button — visible on hover, hidden for root */}
|
||||
{!isRoot && !isGhost && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(node.id)
|
||||
}}
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400 transition-all"
|
||||
title="Delete node"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Decision options preview */}
|
||||
|
||||
@@ -19,6 +19,7 @@ interface TreeEditorLayoutProps {
|
||||
editingNodeId: string | null
|
||||
onNodeSelect: (nodeId: string | null) => void
|
||||
onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
||||
onNodeDelete?: (nodeId: string) => void
|
||||
onNodeContextMenu?: (e: React.MouseEvent, nodeId: string) => void
|
||||
}
|
||||
|
||||
@@ -29,6 +30,7 @@ export function TreeEditorLayout({
|
||||
editingNodeId,
|
||||
onNodeSelect,
|
||||
onSelectAnswerType,
|
||||
onNodeDelete,
|
||||
onNodeContextMenu,
|
||||
}: TreeEditorLayoutProps) {
|
||||
const editorMode = useTreeEditorStore(s => s.editorMode)
|
||||
@@ -72,6 +74,7 @@ export function TreeEditorLayout({
|
||||
selectedNodeId={editingNodeId}
|
||||
onNodeSelect={onNodeSelect}
|
||||
onSelectAnswerType={onSelectAnswerType}
|
||||
onNodeDelete={onNodeDelete}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -190,6 +190,7 @@ export function useTreeLayout(selectedNodeId?: string | null): UseTreeLayoutResu
|
||||
isCollapsed,
|
||||
hasValidationErrors: hasErrors,
|
||||
isNew: false,
|
||||
isRoot: node.id === treeStructure?.id,
|
||||
onToggleCollapse: () => {}, // placeholder — set by FlowCanvas
|
||||
} satisfies FlowCanvasNodeData,
|
||||
style: { width: NODE_WIDTH },
|
||||
|
||||
234
frontend/src/pages/KBAcceleratorPage.tsx
Normal file
234
frontend/src/pages/KBAcceleratorPage.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Sparkles, Loader2 } from 'lucide-react'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { kbAcceleratorApi } from '@/api'
|
||||
import { UploadScreen } from '@/components/kb-accelerator/UploadScreen'
|
||||
import { ReviewScreen } from '@/components/kb-accelerator/ReviewScreen'
|
||||
import { SuccessScreen } from '@/components/kb-accelerator/SuccessScreen'
|
||||
import { getTreeEditorPath } from '@/lib/routing'
|
||||
import type { KBImport, KBQuotaResponse, KBCommitResponse, KBNodeEditRequest } from '@/types/kbAccelerator'
|
||||
|
||||
type Phase = 'upload' | 'processing' | 'review' | 'success'
|
||||
type TargetType = 'troubleshooting' | 'procedural'
|
||||
|
||||
export default function KBAcceleratorPage() {
|
||||
const navigate = useNavigate()
|
||||
const [phase, setPhase] = useState<Phase>('upload')
|
||||
const [quota, setQuota] = useState<KBQuotaResponse | null>(null)
|
||||
const [importId, setImportId] = useState<string | null>(null)
|
||||
const [kbImport, setKbImport] = useState<KBImport | null>(null)
|
||||
const [commitResult, setCommitResult] = useState<KBCommitResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// Load quota on mount
|
||||
useEffect(() => {
|
||||
kbAcceleratorApi.getQuota().then(setQuota).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Poll for processing status
|
||||
const startPolling = useCallback((id: string) => {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const data = await kbAcceleratorApi.get(id)
|
||||
if (data.status === 'ready') {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
setKbImport(data)
|
||||
setPhase('review')
|
||||
} else if (data.status === 'failed') {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
toast.error(data.error_message || 'Conversion failed')
|
||||
setPhase('upload')
|
||||
}
|
||||
} catch {
|
||||
// Keep polling on transient errors
|
||||
}
|
||||
}, 2000)
|
||||
}, [])
|
||||
|
||||
// Cleanup polling on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmitText = async (content: string, title: string, targetType: TargetType) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const resp = await kbAcceleratorApi.uploadText({
|
||||
content,
|
||||
title: title || undefined,
|
||||
target_type: targetType,
|
||||
})
|
||||
setImportId(resp.id)
|
||||
setPhase('processing')
|
||||
startPolling(resp.id)
|
||||
} catch (err: unknown) {
|
||||
const message = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? 'Upload failed'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitFile = async (file: File, targetType: TargetType) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const resp = await kbAcceleratorApi.uploadFile(file, targetType)
|
||||
setImportId(resp.id)
|
||||
setPhase('processing')
|
||||
startPolling(resp.id)
|
||||
} catch (err: unknown) {
|
||||
const message = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? 'Upload failed'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApproveAll = async () => {
|
||||
if (!importId || !kbImport) return
|
||||
const unapproved = kbImport.nodes.filter(n => !n.user_approved)
|
||||
if (unapproved.length === 0) return
|
||||
setLoading(true)
|
||||
try {
|
||||
await Promise.all(
|
||||
unapproved.map(n => kbAcceleratorApi.editNode(importId, n.id, { operation: 'approve' }))
|
||||
)
|
||||
setKbImport(prev => {
|
||||
if (!prev) return prev
|
||||
return { ...prev, nodes: prev.nodes.map(n => ({ ...n, user_approved: true })) }
|
||||
})
|
||||
} catch {
|
||||
toast.error('Failed to approve all nodes')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditNode = async (nodeId: string, data: KBNodeEditRequest) => {
|
||||
if (!importId) return
|
||||
const updatedNode = await kbAcceleratorApi.editNode(importId, nodeId, data)
|
||||
setKbImport(prev => {
|
||||
if (!prev) return prev
|
||||
if (data.operation === 'delete') {
|
||||
return { ...prev, nodes: prev.nodes.filter(n => n.id !== nodeId) }
|
||||
}
|
||||
if (data.operation === 'insert_after') {
|
||||
const idx = prev.nodes.findIndex(n => n.id === nodeId)
|
||||
const newNodes = [...prev.nodes]
|
||||
newNodes.splice(idx + 1, 0, updatedNode)
|
||||
return { ...prev, nodes: newNodes }
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
nodes: prev.nodes.map(n => n.id === updatedNode.id ? updatedNode : n),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!importId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await kbAcceleratorApi.commit(importId)
|
||||
setCommitResult(result)
|
||||
setPhase('success')
|
||||
// Refresh quota
|
||||
kbAcceleratorApi.getQuota().then(setQuota).catch(() => {})
|
||||
} catch (err: unknown) {
|
||||
const detail = (err as { response?: { data?: { detail?: string | { message?: string; validation_errors?: string[] } } } })?.response?.data?.detail
|
||||
if (typeof detail === 'object' && detail !== null) {
|
||||
const msg = detail.message || 'Commit failed'
|
||||
const errors = detail.validation_errors
|
||||
toast.error(errors?.length ? `${msg}\n${errors.join('\n')}` : msg)
|
||||
} else {
|
||||
toast.error(typeof detail === 'string' ? detail : 'Commit failed')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDiscard = async () => {
|
||||
if (!importId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
await kbAcceleratorApi.delete(importId)
|
||||
resetWizard()
|
||||
} catch {
|
||||
toast.error('Failed to discard')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetWizard = () => {
|
||||
setPhase('upload')
|
||||
setImportId(null)
|
||||
setKbImport(null)
|
||||
setCommitResult(null)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 p-6">
|
||||
{/* Page title */}
|
||||
<div className="shrink-0 flex items-center gap-3 mb-6">
|
||||
<Sparkles size={24} className="text-primary" />
|
||||
<h1 className="text-2xl font-heading font-bold text-foreground">KB Accelerator</h1>
|
||||
</div>
|
||||
|
||||
{/* Phases */}
|
||||
{phase === 'upload' && (
|
||||
<UploadScreen
|
||||
quota={quota}
|
||||
onSubmitText={handleSubmitText}
|
||||
onSubmitFile={handleSubmitFile}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'processing' && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4">
|
||||
<Loader2 size={40} className="text-primary animate-spin" />
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-heading font-semibold text-foreground">
|
||||
Converting your KB article...
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
AI is analyzing your content and generating an interactive flow.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'review' && kbImport && (
|
||||
<div className="flex-1 min-h-0">
|
||||
<ReviewScreen
|
||||
kbImport={kbImport}
|
||||
onEditNode={handleEditNode}
|
||||
onApproveAll={handleApproveAll}
|
||||
onCommit={handleCommit}
|
||||
onDiscard={handleDiscard}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'success' && commitResult && (
|
||||
<SuccessScreen
|
||||
result={commitResult}
|
||||
onViewFlow={() => {
|
||||
const path = getTreeEditorPath(commitResult.tree_id, commitResult.tree_type as 'troubleshooting' | 'procedural')
|
||||
navigate(path)
|
||||
}}
|
||||
onConvertAnother={resetWizard}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
514
frontend/src/pages/LandingPage.tsx
Normal file
514
frontend/src/pages/LandingPage.tsx
Normal file
@@ -0,0 +1,514 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import '@/styles/landing.css'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
|
||||
export default function LandingPage() {
|
||||
const [navScrolled, setNavScrolled] = useState(false)
|
||||
const [betaEmail, setBetaEmail] = useState('')
|
||||
const [betaStatus, setBetaStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
|
||||
|
||||
// Nav scroll effect
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setNavScrolled(window.scrollY > 40)
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
// Scroll reveal
|
||||
useEffect(() => {
|
||||
const els = document.querySelectorAll('.landing-reveal')
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) entry.target.classList.add('visible')
|
||||
})
|
||||
},
|
||||
{ threshold: 0.15 }
|
||||
)
|
||||
els.forEach(el => observer.observe(el))
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const handleBetaSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!betaEmail.trim() || betaStatus === 'sending') return
|
||||
setBetaStatus('sending')
|
||||
try {
|
||||
const resp = await fetch(`${API_URL}/api/v1/beta-signup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: betaEmail }),
|
||||
})
|
||||
if (!resp.ok) throw new Error('Signup failed')
|
||||
setBetaStatus('sent')
|
||||
setBetaEmail('')
|
||||
} catch {
|
||||
setBetaStatus('error')
|
||||
setTimeout(() => setBetaStatus('idle'), 3000)
|
||||
}
|
||||
}, [betaEmail, betaStatus])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta
|
||||
title="ResolutionFlow — From Issue to Resolution, Documented"
|
||||
description="AI-guided decision trees that walk your engineers through troubleshooting and automatically document every step."
|
||||
/>
|
||||
|
||||
<div className="landing-page">
|
||||
<div className="landing-ambient-glow" />
|
||||
<div className="landing-grid-pattern" />
|
||||
|
||||
<div className="landing-page-content">
|
||||
{/* Navigation */}
|
||||
<nav className={`landing-nav ${navScrolled ? 'scrolled' : ''}`}>
|
||||
<div className="landing-nav-inner">
|
||||
<a href="#" className="landing-nav-logo">
|
||||
<div className="landing-nav-logo-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="5" r="2"/>
|
||||
<line x1="12" y1="7" x2="12" y2="11"/>
|
||||
<circle cx="6" cy="15" r="2"/>
|
||||
<circle cx="18" cy="15" r="2"/>
|
||||
<line x1="12" y1="11" x2="6" y2="13"/>
|
||||
<line x1="12" y1="11" x2="18" y2="13"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="landing-nav-wordmark">Resolution<span>Flow</span></div>
|
||||
</a>
|
||||
<ul className="landing-nav-links">
|
||||
<li><a href="#features">Features</a></li>
|
||||
<li><a href="#how-it-works">How It Works</a></li>
|
||||
<li><a href="#pricing">Pricing</a></li>
|
||||
</ul>
|
||||
<div className="landing-nav-cta">
|
||||
<Link to="/login" className="landing-btn-ghost">Sign In</Link>
|
||||
<Link to="/register" className="landing-btn-primary">Get Started Free</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="landing-hero">
|
||||
<div className="landing-hero-badge">Now in Beta — Join early access</div>
|
||||
<h1>
|
||||
Stop writing ticket notes.<br />
|
||||
<span className="landing-gradient-text">Start generating them.</span>
|
||||
</h1>
|
||||
<p className="landing-hero-sub">
|
||||
AI-guided decision trees that walk your engineers through troubleshooting — and automatically document every step, ready for your PSA ticket.
|
||||
</p>
|
||||
<div className="landing-hero-actions">
|
||||
<Link to="/register" className="landing-btn-hero-primary">Start Free</Link>
|
||||
<a href="#how-it-works" className="landing-btn-hero-secondary">See How It Works</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Social Proof Bar */}
|
||||
<div className="landing-social-proof-bar">
|
||||
<p>Built by MSP engineers, for MSP engineers</p>
|
||||
<div className="landing-proof-stats">
|
||||
<div className="landing-proof-stat">
|
||||
<div className="number">15+</div>
|
||||
<div className="label">Years MSP Experience</div>
|
||||
</div>
|
||||
<div className="landing-proof-stat">
|
||||
<div className="number">70%</div>
|
||||
<div className="label">Less Time on Documentation</div>
|
||||
</div>
|
||||
<div className="landing-proof-stat">
|
||||
<div className="number">0</div>
|
||||
<div className="label">Ticket Notes Written by Hand</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* App Preview */}
|
||||
<div className="landing-app-preview">
|
||||
<div className="landing-preview-window">
|
||||
<div className="landing-preview-titlebar">
|
||||
<div className="landing-preview-tab">
|
||||
<div className="landing-tab-icon" />
|
||||
ResolutionFlow
|
||||
<span className="landing-tab-close">×</span>
|
||||
</div>
|
||||
<div className="landing-preview-url-bar">
|
||||
<div className="landing-preview-url">
|
||||
<span className="landing-lock-icon">🔒</span>
|
||||
app.resolutionflow.com/editor
|
||||
</div>
|
||||
</div>
|
||||
<div className="landing-preview-window-controls">
|
||||
<div className="landing-win-btn">
|
||||
<svg viewBox="0 0 12 12"><line x1="2" y1="6" x2="10" y2="6"/></svg>
|
||||
</div>
|
||||
<div className="landing-win-btn">
|
||||
<svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8" rx="0.5"/></svg>
|
||||
</div>
|
||||
<div className="landing-win-btn close">
|
||||
<svg viewBox="0 0 12 12"><line x1="2" y1="2" x2="10" y2="10"/><line x1="10" y1="2" x2="2" y2="10"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="landing-preview-body">
|
||||
<div className="landing-preview-sidebar">
|
||||
<div className="landing-preview-sidebar-item active">
|
||||
<div className="dot" style={{ background: 'var(--cyan-400)' }} />
|
||||
Flow Editor
|
||||
</div>
|
||||
<div className="landing-preview-sidebar-item">
|
||||
<div className="dot" style={{ background: '#3b82f6' }} />
|
||||
Session Runner
|
||||
</div>
|
||||
<div className="landing-preview-sidebar-item">
|
||||
<div className="dot" style={{ background: '#22c55e' }} />
|
||||
Flow Library
|
||||
</div>
|
||||
<div className="landing-preview-sidebar-item">
|
||||
<div className="dot" style={{ background: '#f59e0b' }} />
|
||||
Session History
|
||||
</div>
|
||||
<div className="landing-preview-sidebar-item">
|
||||
<div className="dot" style={{ background: '#8b5cf6' }} />
|
||||
Team Analytics
|
||||
</div>
|
||||
</div>
|
||||
<div className="landing-preview-canvas">
|
||||
<div className="landing-mini-tree">
|
||||
<div className="landing-tree-node root">Outlook Not Syncing</div>
|
||||
<div className="landing-tree-connector" />
|
||||
<div className="landing-tree-branch">
|
||||
<div className="landing-tree-branch-arm">
|
||||
<div className="landing-tree-label">Yes</div>
|
||||
<div className="landing-tree-connector" />
|
||||
<div className="landing-tree-node decision">Check profile config</div>
|
||||
</div>
|
||||
<div className="landing-tree-branch-arm">
|
||||
<div className="landing-tree-label">No</div>
|
||||
<div className="landing-tree-connector" />
|
||||
<div className="landing-tree-node decision">Verify credentials</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: '5rem' }} />
|
||||
<div className="landing-section-divider" />
|
||||
|
||||
{/* Problem Section */}
|
||||
<section id="problem" className="landing-reveal">
|
||||
<div className="landing-section-inner">
|
||||
<div className="landing-section-label">The Problem</div>
|
||||
<div className="landing-section-title">Documentation is broken.<br />Everyone knows it.</div>
|
||||
<div className="landing-section-desc">
|
||||
Engineers don't want to write it. Managers hate chasing it. Clients never see it. The same issues get solved from scratch every time.
|
||||
</div>
|
||||
<div className="landing-problem-grid">
|
||||
<ProblemCard icon="⏱" color="red" title="15–25 min lost per ticket" description="Engineers spend more time documenting what they did than actually doing it. After a complex issue, writing notes is the last thing anyone wants to do." />
|
||||
<ProblemCard icon="📋" color="amber" title="Vague, useless notes" description={`"Fixed Outlook" tells you nothing. Documentation written under pressure tends toward generalities that help nobody the second time around.`} />
|
||||
<ProblemCard icon="🔄" color="slate" title="Knowledge walks out the door" description="When a senior engineer leaves, years of tribal knowledge disappear overnight. New hires spend months building up what was never captured." />
|
||||
<ProblemCard icon="🧠" color="violet" title="Context switching kills speed" description="Jumping between the issue, documentation tools, PSA tickets, and knowledge bases fragments focus and slows resolution." />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="landing-section-divider" />
|
||||
|
||||
{/* Brand Equation */}
|
||||
<div className="landing-equation-section landing-reveal">
|
||||
<div className="landing-section-label">The Answer</div>
|
||||
<div className="landing-brand-equation">
|
||||
<span className="landing-eq-item">Resolution</span>
|
||||
<span className="landing-eq-operator">+</span>
|
||||
<span className="landing-eq-item">Documentation</span>
|
||||
<span className="landing-eq-operator">−</span>
|
||||
<span className="landing-eq-item">Time</span>
|
||||
<span className="landing-eq-operator">=</span>
|
||||
<span className="landing-eq-result">ResolutionFlow</span>
|
||||
</div>
|
||||
<p className="landing-equation-desc">
|
||||
What if documentation was a <em>byproduct</em> of solving the issue — not a separate task? What if your engineers never had to write another ticket note?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="landing-section-divider" />
|
||||
|
||||
{/* How It Works */}
|
||||
<section id="how-it-works" className="landing-reveal">
|
||||
<div className="landing-section-inner">
|
||||
<div className="landing-section-label">How It Works</div>
|
||||
<div className="landing-section-title">Three steps. Zero note-writing.</div>
|
||||
<div className="landing-section-desc">
|
||||
Build once, run forever. Every session generates documentation automatically.
|
||||
</div>
|
||||
<div className="landing-steps-container">
|
||||
<div className="landing-step-card">
|
||||
<h3>Build a Flow</h3>
|
||||
<p>Use the visual Flow Editor to create branching decision trees for any troubleshooting scenario. Drag, connect, and enrich steps with commands, notes, and AI suggestions.</p>
|
||||
<div className="landing-step-visual">
|
||||
<div className="landing-mock-editor">
|
||||
<div className="landing-mock-node start">▶ Start</div>
|
||||
<div className="landing-mock-connector">→</div>
|
||||
<div className="landing-mock-node step">Check DNS</div>
|
||||
<div className="landing-mock-connector">→</div>
|
||||
<div className="landing-mock-node step">Yes / No?</div>
|
||||
<div className="landing-mock-connector">→</div>
|
||||
<div className="landing-mock-node start">✓ Resolved</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="landing-step-card">
|
||||
<h3>Run a Session</h3>
|
||||
<p>An engineer launches the flow on a live ticket. FlowPilot — your AI copilot — acts as a virtual senior engineer, guiding decisions and capturing every action in real time.</p>
|
||||
<div className="landing-step-visual">
|
||||
<div className="landing-mock-session">
|
||||
<div className="landing-mock-chat-line">
|
||||
<span className="label">FlowPilot:</span>
|
||||
<span className="text">Is the user on VPN?</span>
|
||||
</div>
|
||||
<div className="landing-mock-chat-line">
|
||||
<span className="label" style={{ color: 'var(--text-secondary)' }}>Engineer:</span>
|
||||
<span className="text">Yes, Cisco AnyConnect</span>
|
||||
</div>
|
||||
<div className="landing-mock-chat-line">
|
||||
<span className="label">FlowPilot:</span>
|
||||
<span className="text">Check split tunnel config →</span>
|
||||
</div>
|
||||
<div className="landing-mock-chat-line doc">
|
||||
<span className="label">Auto-doc:</span>
|
||||
<span className="text">Step captured ✓</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="landing-step-card">
|
||||
<h3>Export to Ticket</h3>
|
||||
<p>When the session ends, full documentation is generated — formatted for your PSA. Paste it directly into ConnectWise, Atera, or Syncro. Done.</p>
|
||||
<div className="landing-step-visual">
|
||||
<div className="landing-mock-ticket">
|
||||
<div className="landing-mock-ticket-header">ConnectWise Ticket #48291</div>
|
||||
<div className="landing-mock-ticket-line"><span className="time">10:04</span><span className="check">✓</span><span>Verified VPN connection active</span></div>
|
||||
<div className="landing-mock-ticket-line"><span className="time">10:06</span><span className="check">✓</span><span>Split tunnel misconfigured — fixed</span></div>
|
||||
<div className="landing-mock-ticket-line"><span className="time">10:08</span><span className="check">✓</span><span>Confirmed Outlook sync restored</span></div>
|
||||
<div className="landing-mock-ticket-line"><span className="time">10:09</span><span className="check">✓</span><span>Resolution: VPN split tunnel updated</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="landing-section-divider" />
|
||||
|
||||
{/* Features */}
|
||||
<section id="features" className="landing-reveal">
|
||||
<div className="landing-section-inner">
|
||||
<div className="landing-section-label">Features</div>
|
||||
<div className="landing-section-title">Everything your team needs to<br />resolve faster and document better.</div>
|
||||
<div className="landing-features-grid">
|
||||
<FeatureCard
|
||||
highlight
|
||||
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>}
|
||||
title="FlowPilot — Your AI Copilot"
|
||||
description="Like having a senior engineer on every call. FlowPilot suggests next steps, provides context-aware guidance, and automatically captures documentation as a byproduct of the troubleshooting session."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>}
|
||||
title="Visual Flow Editor"
|
||||
description="Build branching decision trees with a drag-and-drop canvas. Add steps, conditions, commands, and notes — no code required."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>}
|
||||
title="Auto-Documentation"
|
||||
description="Every session generates timestamped, detailed notes — formatted for your PSA. Engineers never write another ticket note."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>}
|
||||
title="Team Knowledge Sharing"
|
||||
description="Share flows across your team. When one engineer solves a new problem, the whole team benefits from that path — instantly."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>}
|
||||
title="Session History & Analytics"
|
||||
description="Track which flows are used most, identify bottlenecks, and see how your team resolves issues over time."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>}
|
||||
title="PSA Integration"
|
||||
description="Connect directly to ConnectWise, Atera, and Syncro. Export session docs straight to tickets — no copy-paste needed."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="landing-section-divider" />
|
||||
|
||||
{/* Pricing */}
|
||||
<section id="pricing" className="landing-reveal">
|
||||
<div className="landing-section-inner">
|
||||
<div className="landing-section-label">Pricing</div>
|
||||
<div className="landing-section-title">Simple pricing. No surprises.</div>
|
||||
<div className="landing-section-desc">Start free. Upgrade when your team is ready.</div>
|
||||
<div className="landing-pricing-grid">
|
||||
<PricingCard
|
||||
name="Free"
|
||||
target="For individual techs evaluating"
|
||||
amount="$0"
|
||||
note="Free forever"
|
||||
features={['3 decision trees', '20 sessions per month', 'Auto-documentation export', 'Session history (30 days)', 'Community support']}
|
||||
btnLabel="Get Started"
|
||||
btnStyle="outline"
|
||||
/>
|
||||
<PricingCard
|
||||
featured
|
||||
name="Pro"
|
||||
target="For small MSPs with 1–5 techs"
|
||||
amount="$15"
|
||||
period="/user/mo"
|
||||
note="Billed monthly or annually"
|
||||
features={['Unlimited decision trees', 'Unlimited sessions', 'FlowPilot AI copilot', 'Auto-documentation export', 'Full session history', 'Flow templates library', 'Priority support']}
|
||||
btnLabel="Start Free Trial"
|
||||
btnStyle="filled"
|
||||
/>
|
||||
<PricingCard
|
||||
name="Team"
|
||||
target="For growing MSPs with 5–25 techs"
|
||||
amount="$25"
|
||||
period="/user/mo"
|
||||
note="Billed monthly or annually"
|
||||
features={['Everything in Pro', 'PSA integration (ConnectWise, Atera, Syncro)', 'Team analytics dashboard', 'Session sharing & collaboration', 'Client context system', 'Role-based permissions', 'Dedicated support']}
|
||||
btnLabel="Start Free Trial"
|
||||
btnStyle="outline"
|
||||
/>
|
||||
</div>
|
||||
<p className="landing-pricing-enterprise">
|
||||
Need Enterprise (25+ techs, SSO, custom branding)?{' '}
|
||||
<a href="mailto:hello@resolutionflow.com">Contact us</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="landing-section-divider" />
|
||||
|
||||
{/* Testimonial */}
|
||||
<div className="landing-testimonial-section landing-reveal">
|
||||
<div className="landing-testimonial-quote">
|
||||
We used to spend more time writing ticket notes than solving the actual issue. Now it just… happens. The documentation writes itself while we work.
|
||||
</div>
|
||||
<div className="landing-testimonial-author">
|
||||
<strong>Beta Tester</strong> — MSP Engineer, Southeast US
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="landing-section-divider" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className="landing-cta-section landing-reveal">
|
||||
<h2>Ready to stop writing ticket notes?</h2>
|
||||
<p>Join the beta and see what happens when documentation becomes automatic.</p>
|
||||
<form className="landing-cta-email-form" onSubmit={handleBetaSubmit}>
|
||||
<input
|
||||
type="email"
|
||||
className="landing-cta-email-input"
|
||||
placeholder="you@yourmsp.com"
|
||||
value={betaEmail}
|
||||
onChange={e => setBetaEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="landing-btn-hero-primary" style={{ whiteSpace: 'nowrap' }} disabled={betaStatus === 'sending'}>
|
||||
{betaStatus === 'sending' ? 'Joining...' : betaStatus === 'sent' ? 'Joined!' : 'Join Beta'}
|
||||
</button>
|
||||
</form>
|
||||
{betaStatus === 'sent' && (
|
||||
<p className="landing-cta-success">Thanks! We'll be in touch with beta access details.</p>
|
||||
)}
|
||||
{betaStatus === 'error' && (
|
||||
<p className="landing-cta-error">Something went wrong. Please try again.</p>
|
||||
)}
|
||||
<p className="landing-cta-fine-print">Free to start. No credit card required.</p>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="landing-footer">
|
||||
<div className="landing-footer-inner">
|
||||
<div className="landing-footer-left">
|
||||
<div className="landing-nav-logo-icon" style={{ width: 28, height: 28, borderRadius: 8 }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ width: 16, height: 16 }}>
|
||||
<circle cx="12" cy="5" r="2"/>
|
||||
<line x1="12" y1="7" x2="12" y2="11"/>
|
||||
<circle cx="6" cy="15" r="2"/>
|
||||
<circle cx="18" cy="15" r="2"/>
|
||||
<line x1="12" y1="11" x2="6" y2="13"/>
|
||||
<line x1="12" y1="11" x2="18" y2="13"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="landing-footer-copy">© 2026 ResolutionFlow. All rights reserved.</span>
|
||||
</div>
|
||||
<ul className="landing-footer-links">
|
||||
<li><a href="#">Privacy</a></li>
|
||||
<li><a href="#">Terms</a></li>
|
||||
<li><a href="mailto:hello@resolutionflow.com">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/* ---- Sub-components ---- */
|
||||
|
||||
function ProblemCard({ icon, color, title, description }: {
|
||||
icon: string; color: string; title: string; description: string
|
||||
}) {
|
||||
return (
|
||||
<div className="landing-problem-card">
|
||||
<div className={`landing-problem-icon ${color}`}>{icon}</div>
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureCard({ icon, title, description, highlight }: {
|
||||
icon: React.ReactNode; title: string; description: string; highlight?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={`landing-feature-card ${highlight ? 'highlight' : ''}`}>
|
||||
<div className="landing-feature-icon">{icon}</div>
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PricingCard({ name, target, amount, period, note, features, btnLabel, btnStyle, featured }: {
|
||||
name: string; target: string; amount: string; period?: string; note: string
|
||||
features: string[]; btnLabel: string; btnStyle: 'outline' | 'filled'; featured?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={`landing-pricing-card ${featured ? 'featured' : ''}`}>
|
||||
<div className="landing-pricing-plan-name">{name}</div>
|
||||
<div className="landing-pricing-target">{target}</div>
|
||||
<div className="landing-pricing-price">
|
||||
<span className="amount">{amount}</span>
|
||||
{period && <span className="period">{period}</span>}
|
||||
</div>
|
||||
<div className="landing-pricing-note">{note}</div>
|
||||
<ul className="landing-pricing-features">
|
||||
{features.map(f => <li key={f}>{f}</li>)}
|
||||
</ul>
|
||||
<Link to="/register" className={`landing-pricing-btn ${btnStyle}`}>{btnLabel}</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { StaggerList } from '@/components/common/StaggerList'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import type { Session, TreeListItem } from '@/types'
|
||||
import type { Session, TreeListItem, SessionOutcome } from '@/types'
|
||||
import type { DateRange } from 'react-day-picker'
|
||||
import { SessionFilters } from '@/components/session/SessionFilters'
|
||||
import type { SessionFilterState } from '@/components/session/SessionFilters'
|
||||
@@ -24,6 +23,13 @@ export function SessionHistoryPage() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [filter, setFilter] = useState<'all' | 'completed' | 'active' | 'prepared'>('all')
|
||||
|
||||
// Close session popover state
|
||||
const [closingSessionId, setClosingSessionId] = useState<string | null>(null)
|
||||
const [closeOutcome, setCloseOutcome] = useState<SessionOutcome | ''>('')
|
||||
const [closeNotes, setCloseNotes] = useState('')
|
||||
const [closeLoading, setCloseLoading] = useState(false)
|
||||
const closePopoverRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Initialize filters from URL params
|
||||
const [filters, setFilters] = useState<SessionFilterState>(() => {
|
||||
const ticketNumber = searchParams.get('ticket') || ''
|
||||
@@ -147,6 +153,46 @@ export function SessionHistoryPage() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleCloseSession = useCallback(async () => {
|
||||
if (!closingSessionId || !closeOutcome) return
|
||||
setCloseLoading(true)
|
||||
try {
|
||||
await sessionsApi.complete(closingSessionId, {
|
||||
outcome: closeOutcome,
|
||||
outcome_notes: closeNotes || undefined,
|
||||
})
|
||||
setSessions(prev =>
|
||||
prev.map(s =>
|
||||
s.id === closingSessionId
|
||||
? { ...s, completed_at: new Date().toISOString(), outcome: closeOutcome, outcome_notes: closeNotes || null }
|
||||
: s
|
||||
)
|
||||
)
|
||||
toast.success('Session closed')
|
||||
setClosingSessionId(null)
|
||||
setCloseOutcome('')
|
||||
setCloseNotes('')
|
||||
} catch {
|
||||
toast.error('Failed to close session')
|
||||
} finally {
|
||||
setCloseLoading(false)
|
||||
}
|
||||
}, [closingSessionId, closeOutcome, closeNotes])
|
||||
|
||||
// Close popover on click outside
|
||||
useEffect(() => {
|
||||
if (!closingSessionId) return
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (closePopoverRef.current && !closePopoverRef.current.contains(e.target as Node)) {
|
||||
setClosingSessionId(null)
|
||||
setCloseOutcome('')
|
||||
setCloseNotes('')
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [closingSessionId])
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
@@ -157,9 +203,15 @@ export function SessionHistoryPage() {
|
||||
|
||||
const formatOutcomeLabel = (outcome: Session['outcome']): string => {
|
||||
if (!outcome) return 'Not set'
|
||||
return outcome === 'workaround'
|
||||
? 'Workaround'
|
||||
: outcome.charAt(0).toUpperCase() + outcome.slice(1)
|
||||
const labels: Record<string, string> = {
|
||||
resolved: 'Resolved',
|
||||
escalated: 'Escalated',
|
||||
workaround: 'Workaround',
|
||||
unresolved: 'Unresolved',
|
||||
cancelled: 'Cancelled',
|
||||
resolved_externally: 'Resolved Externally',
|
||||
}
|
||||
return labels[outcome] ?? outcome
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -222,10 +274,17 @@ export function SessionHistoryPage() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<StaggerList className="space-y-4">
|
||||
{sessions.map((session) => (
|
||||
<div className="space-y-4">
|
||||
{sessions.map((session, i) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={cn(
|
||||
'stagger-item',
|
||||
closingSessionId === session.id && 'relative z-50'
|
||||
)}
|
||||
style={{ '--stagger-index': i } as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
className="bg-card border border-border rounded-xl p-4 transition-all hover:bg-accent/50"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
@@ -254,6 +313,8 @@ export function SessionHistoryPage() {
|
||||
session.outcome === 'workaround' && 'bg-amber-500/20 text-amber-300',
|
||||
session.outcome === 'escalated' && 'bg-blue-500/20 text-blue-300',
|
||||
session.outcome === 'unresolved' && 'bg-rose-500/20 text-rose-300',
|
||||
session.outcome === 'cancelled' && 'bg-zinc-500/20 text-zinc-300',
|
||||
session.outcome === 'resolved_externally' && 'bg-cyan-500/20 text-cyan-300',
|
||||
!session.outcome && 'bg-accent text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
@@ -285,7 +346,7 @@ export function SessionHistoryPage() {
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/sessions/${session.id}`)}
|
||||
className={cn(
|
||||
@@ -295,22 +356,99 @@ export function SessionHistoryPage() {
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
{!session.completed_at && (
|
||||
<button
|
||||
onClick={() => navigate(getSessionResumePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
|
||||
className={cn(
|
||||
'rounded-md bg-gradient-brand px-3 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90'
|
||||
)}
|
||||
{!session.completed_at && session.started_at && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setClosingSessionId(closingSessionId === session.id ? null : session.id)
|
||||
setCloseOutcome('')
|
||||
setCloseNotes('')
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground',
|
||||
closingSessionId === session.id && 'bg-accent text-foreground'
|
||||
)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(getSessionResumePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
|
||||
className={cn(
|
||||
'rounded-md bg-gradient-brand px-3 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90'
|
||||
)}
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Close Session Popover */}
|
||||
{closingSessionId === session.id && (
|
||||
<div
|
||||
ref={closePopoverRef}
|
||||
className="absolute right-0 top-full z-20 mt-2 w-72 rounded-xl border border-border bg-card p-4 shadow-xl"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
<p className="text-sm font-heading font-medium text-foreground mb-3">Close Session</p>
|
||||
|
||||
<label className="block text-xs font-label text-muted-foreground mb-1">Outcome</label>
|
||||
<select
|
||||
value={closeOutcome}
|
||||
onChange={(e) => setCloseOutcome(e.target.value as SessionOutcome)}
|
||||
title="Session outcome"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none mb-3"
|
||||
>
|
||||
<option value="">Select outcome...</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="escalated">Escalated</option>
|
||||
<option value="workaround">Workaround</option>
|
||||
<option value="unresolved">Unresolved</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="resolved_externally">Resolved Externally</option>
|
||||
</select>
|
||||
|
||||
<label className="block text-xs font-label text-muted-foreground mb-1">Notes (optional)</label>
|
||||
<textarea
|
||||
value={closeNotes}
|
||||
onChange={(e) => setCloseNotes(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Add closure notes..."
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none mb-3"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setClosingSessionId(null)
|
||||
setCloseOutcome('')
|
||||
setCloseNotes('')
|
||||
}}
|
||||
className="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCloseSession}
|
||||
disabled={!closeOutcome || closeLoading}
|
||||
className={cn(
|
||||
'rounded-lg px-4 py-1.5 text-sm font-medium shadow-lg shadow-primary/20 transition-opacity',
|
||||
closeOutcome
|
||||
? 'bg-gradient-brand text-[#101114] hover:opacity-90'
|
||||
: 'bg-gradient-brand text-[#101114] opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{closeLoading ? 'Closing...' : 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</StaggerList>
|
||||
</div>
|
||||
{hasMore ? (
|
||||
<p className="text-center text-sm text-muted-foreground py-4">
|
||||
Showing the 50 most recent sessions
|
||||
|
||||
@@ -815,6 +815,7 @@ export function TreeEditorPage() {
|
||||
editingNodeId={editorAI.isOpen ? null : editingNodeId}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
onSelectAnswerType={handleSelectAnswerType}
|
||||
onNodeDelete={deleteNode}
|
||||
onNodeContextMenu={editorAI.openContextMenu}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -39,8 +39,8 @@ export function TreeNavigationPage() {
|
||||
|
||||
const [tree, setTree] = useState<Tree | null>(null)
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
const [currentNodeId, setCurrentNodeId] = useState<string>('root')
|
||||
const [pathTaken, setPathTaken] = useState<string[]>(['root'])
|
||||
const [currentNodeId, setCurrentNodeId] = useState<string>('')
|
||||
const [pathTaken, setPathTaken] = useState<string[]>([])
|
||||
const [decisions, setDecisions] = useState<DecisionRecord[]>([])
|
||||
const [currentStepEnteredAt, setCurrentStepEnteredAt] = useState<string>(new Date().toISOString())
|
||||
const [notes, setNotes] = useState<string>('')
|
||||
@@ -294,7 +294,7 @@ export function TreeNavigationPage() {
|
||||
const sessionData = await sessionsApi.get(locationState.sessionId)
|
||||
setSession(sessionData)
|
||||
setPathTaken(sessionData.path_taken)
|
||||
setCurrentNodeId(sessionData.path_taken[sessionData.path_taken.length - 1] || 'root')
|
||||
setCurrentNodeId(sessionData.path_taken[sessionData.path_taken.length - 1] || treeData.tree_structure?.id || 'root')
|
||||
setDecisions(sessionData.decisions as DecisionRecord[])
|
||||
setCurrentStepEnteredAt(deriveCurrentStepEnteredAt(sessionData))
|
||||
customStepFlow.initCustomSteps(sessionData.custom_steps || [])
|
||||
@@ -320,6 +320,10 @@ export function TreeNavigationPage() {
|
||||
client_name: clientName || undefined,
|
||||
})
|
||||
setSession(newSession)
|
||||
// Initialize currentNodeId to the tree's actual root (may not be 'root')
|
||||
const rootId = tree.tree_structure?.id || 'root'
|
||||
setCurrentNodeId(rootId)
|
||||
setPathTaken([rootId])
|
||||
setCurrentStepEnteredAt(newSession.started_at || new Date().toISOString())
|
||||
setShowMetadataForm(false)
|
||||
// Save for "Repeat Last Session"
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/pages'
|
||||
|
||||
// Public pages
|
||||
const LandingPage = lazy(() => import('@/pages/LandingPage'))
|
||||
const SharedSessionPage = lazy(() => import('@/pages/SharedSessionPage'))
|
||||
const SurveyPage = lazy(() => import('@/pages/SurveyPage'))
|
||||
const SurveyThankYouPage = lazy(() => import('@/pages/SurveyThankYouPage'))
|
||||
@@ -41,6 +42,7 @@ const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage'))
|
||||
const FeedbackPage = lazy(() => import('@/pages/FeedbackPage'))
|
||||
const StepLibraryPage = lazy(() => import('@/pages/StepLibraryPage'))
|
||||
const AssistantChatPage = lazy(() => import('@/pages/AssistantChatPage'))
|
||||
const KBAcceleratorPage = lazy(() => import('@/pages/KBAcceleratorPage'))
|
||||
const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage'))
|
||||
const GuideDetailPage = lazy(() => import('@/pages/GuideDetailPage'))
|
||||
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
|
||||
@@ -77,6 +79,11 @@ function page(Component: React.LazyExoticComponent<React.ComponentType>) {
|
||||
}
|
||||
|
||||
export const router = sentryCreateBrowserRouter([
|
||||
{
|
||||
path: '/landing',
|
||||
element: page(LandingPage),
|
||||
errorElement: <RouteError />,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
element: <LoginPage />,
|
||||
@@ -153,6 +160,7 @@ export const router = sentryCreateBrowserRouter([
|
||||
{ path: 'analytics/me', element: page(MyAnalyticsPage) },
|
||||
{ path: 'feedback', element: page(FeedbackPage) },
|
||||
{ path: 'step-library', element: page(StepLibraryPage) },
|
||||
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
|
||||
{ path: 'assistant', element: page(AssistantChatPage) },
|
||||
{ path: 'guides', element: page(GuidesHubPage) },
|
||||
{ path: 'guides/:slug', element: page(GuideDetailPage) },
|
||||
|
||||
1441
frontend/src/styles/landing.css
Normal file
1441
frontend/src/styles/landing.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -73,3 +73,17 @@ export type {
|
||||
ContextMenuPosition,
|
||||
SuggestionMarker,
|
||||
} from './editor-ai'
|
||||
|
||||
export type {
|
||||
KBUploadTextRequest,
|
||||
KBNodeEditRequest,
|
||||
KBCommitRequest,
|
||||
KBListParams,
|
||||
KBImportNode,
|
||||
KBUploadResponse,
|
||||
KBImport,
|
||||
KBImportSummary,
|
||||
KBImportListResponse,
|
||||
KBCommitResponse,
|
||||
KBQuotaResponse,
|
||||
} from './kbAccelerator'
|
||||
|
||||
108
frontend/src/types/kbAccelerator.ts
Normal file
108
frontend/src/types/kbAccelerator.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* KB Accelerator types — converts KB articles into interactive flows.
|
||||
*/
|
||||
|
||||
// ── Requests ──
|
||||
|
||||
export interface KBUploadTextRequest {
|
||||
content: string
|
||||
title?: string
|
||||
target_type?: 'troubleshooting' | 'procedural'
|
||||
}
|
||||
|
||||
export interface KBNodeEditRequest {
|
||||
operation: 'approve' | 'reject' | 'edit' | 'delete' | 'regenerate' | 'insert_after'
|
||||
content?: Record<string, unknown>
|
||||
guidance?: string
|
||||
}
|
||||
|
||||
export interface KBCommitRequest {
|
||||
name?: string
|
||||
description?: string
|
||||
category_id?: string
|
||||
}
|
||||
|
||||
export interface KBListParams {
|
||||
skip?: number
|
||||
limit?: number
|
||||
status?: string
|
||||
}
|
||||
|
||||
// ── Responses ──
|
||||
|
||||
export interface KBImportNode {
|
||||
id: string
|
||||
kb_import_id: string
|
||||
node_order: number
|
||||
node_type: string
|
||||
content: Record<string, unknown>
|
||||
parent_node_id: string | null
|
||||
source_excerpt: string | null
|
||||
confidence_score: number
|
||||
user_edited: boolean
|
||||
user_approved: boolean
|
||||
}
|
||||
|
||||
export interface KBUploadResponse {
|
||||
id: string
|
||||
status: string
|
||||
source_format: string
|
||||
}
|
||||
|
||||
export interface KBImport {
|
||||
id: string
|
||||
account_id: string
|
||||
created_by: string
|
||||
source_filename: string | null
|
||||
source_format: string
|
||||
source_text: string
|
||||
source_metadata: Record<string, unknown> | null
|
||||
target_type: string
|
||||
status: string
|
||||
confidence_avg: number | null
|
||||
error_message: string | null
|
||||
processing_time_ms: number | null
|
||||
ai_tokens_input: number | null
|
||||
ai_tokens_output: number | null
|
||||
tree_id: string | null
|
||||
nodes: KBImportNode[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface KBImportSummary {
|
||||
id: string
|
||||
source_filename: string | null
|
||||
source_format: string
|
||||
target_type: string
|
||||
status: string
|
||||
confidence_avg: number | null
|
||||
node_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface KBImportListResponse {
|
||||
items: KBImportSummary[]
|
||||
total: number
|
||||
skip: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
export interface KBCommitResponse {
|
||||
tree_id: string
|
||||
import_id: string
|
||||
tree_type: string
|
||||
}
|
||||
|
||||
export interface KBQuotaResponse {
|
||||
plan: string
|
||||
kb_accelerator_enabled: boolean
|
||||
lifetime_conversions_used: number
|
||||
lifetime_conversions_limit: number | null
|
||||
allowed_formats: string[]
|
||||
detailed_analysis: boolean
|
||||
conversational_refinement: boolean
|
||||
step_library_matching: boolean
|
||||
history_limit: number | null
|
||||
can_convert: boolean
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TreeStructure } from './tree'
|
||||
import type { Step, StepContent } from './step'
|
||||
|
||||
export type SessionOutcome = 'resolved' | 'escalated' | 'workaround' | 'unresolved'
|
||||
export type SessionOutcome = 'resolved' | 'escalated' | 'workaround' | 'unresolved' | 'cancelled' | 'resolved_externally'
|
||||
|
||||
export interface DecisionRecord {
|
||||
node_id: string
|
||||
|
||||
Reference in New Issue
Block a user