Arslan1997's picture
added latest models
8520346
Raw
History Blame Contribute Delete
57.2 kB
"""
Forms API Routes
================
Routes for form CRUD operations, questions, and conditional logic.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import desc
from typing import List
import secrets
from datetime import datetime
from ..core.db import get_db
from ..core.security import get_current_user
from ..models import User, Form, FormQuestion, ConditionalRule, PublicForm, QuestionType, ConditionType, FormResponse as FormResponseModel, ResponseAnswer, ChatMessage as ChatMessageModel, FormUpload, Subscription
from ..schemas.form import (
FormCreate, FormUpdate, FormResponse, FormGenerationResponse,
QuestionCreate, QuestionUpdate, QuestionResponse,
ConditionalRuleCreate, ConditionalRuleResponse,
PublicFormCreate, PublicFormResponse,
ChatMessage as ChatMessageSchema, ChatResponse
)
from ..services.form_creator import generate_form_spec, edit_form_spec, validate_question_type, validate_condition_type
from ..services.credit_service import credit_service
from ..services.email_service import email_service, EmailServiceError
import logging
import re
import asyncio
logger = logging.getLogger(__name__)
def repair_json(text: str) -> str:
"""
Repair common JSON issues from LLM output.
- Remove // comments
- Remove /* */ comments
- Fix trailing commas before } or ]
"""
if not text:
return text
# Remove single-line comments (// ...)
text = re.sub(r'//[^\n]*', '', text)
# Remove multi-line comments (/* ... */)
text = re.sub(r'/\*.*?\*/', '', text, flags=re.DOTALL)
# Remove trailing commas before } or ]
text = re.sub(r',(\s*[}\]])', r'\1', text)
# Remove any BOM or weird unicode
text = text.strip().lstrip('\ufeff')
return text
router = APIRouter(prefix="/api/forms", tags=["forms"])
@router.post("/generate", response_model=FormGenerationResponse)
async def generate_form(
form_data: FormCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Generate a new form from natural language description using AI.
Costs 5 credits.
"""
# Check credits
if not credit_service.check_sufficient_credits(db, current_user.id, 5):
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail="Insufficient credits. Please upgrade your plan."
)
try:
# Generate form using AI
form_spec, questions_spec, rules_spec = await generate_form_spec(
user_query=form_data.user_query,
user_id=current_user.id
)
# Create form in database
new_form = Form(
user_id=current_user.id,
title=form_spec.get("title", "New Form"),
description=form_spec.get("description"),
settings=form_spec.get("settings", {})
)
db.add(new_form)
db.flush() # Get form ID
# Create questions
question_id_map = {} # Map order to actual ID
for q_spec in questions_spec:
# Final safety check: ensure matrix/ranking settings are lists
settings = q_spec.get("settings", {})
question_type = q_spec.get("question_type", "")
if question_type == "matrix":
if isinstance(settings.get("rows"), (int, float)):
num_rows = max(1, int(settings["rows"]))
settings["rows"] = [f"Row {i+1}" for i in range(num_rows)]
if isinstance(settings.get("columns"), (int, float)):
num_cols = max(1, int(settings["columns"]))
settings["columns"] = [f"Column {i+1}" for i in range(num_cols)]
if question_type == "ranking":
if isinstance(settings.get("ranking_items"), (int, float)):
num_items = max(2, int(settings["ranking_items"]))
settings["ranking_items"] = [f"Item {i+1}" for i in range(num_items)]
question = FormQuestion(
form_id=new_form.id,
question_order=q_spec["question_order"],
question_type=QuestionType(q_spec["question_type"]),
question_text=q_spec["question_text"],
description=q_spec.get("description"),
required=q_spec.get("required", False),
settings=settings
)
db.add(question)
db.flush()
question_id_map[q_spec["question_order"]] = question.id
# Create conditional rules
for rule_spec in rules_spec:
trigger_idx = rule_spec.get("trigger_question_index")
target_idx = rule_spec.get("target_question_index")
if trigger_idx in question_id_map and target_idx in question_id_map:
rule = ConditionalRule(
form_id=new_form.id,
trigger_question_id=question_id_map[trigger_idx],
target_question_id=question_id_map[target_idx],
condition_type=ConditionType(rule_spec.get("condition_type", "equals")),
condition_value=rule_spec.get("condition_value"),
action=rule_spec.get("action", "show")
)
db.add(rule)
db.commit()
db.refresh(new_form)
# Deduct credits
credit_service.deduct_credits(
user_id=current_user.id,
amount=5,
description=f"Generated form: {new_form.title}",
db=db
)
# Send feedback email asynchronously (non-blocking)
# Only send to users with paid plans (plan_id > 1)
form_count = db.query(Form).filter(Form.user_id == current_user.id).count()
task = asyncio.create_task(_send_feedback_email_if_eligible(
user_id=current_user.id,
user_email=current_user.email,
user_name=current_user.name,
form_count=form_count
))
# Add done callback to log any exceptions
task.add_done_callback(lambda t: logger.error(f"Email task error: {t.exception()}") if t.exception() else None)
return FormGenerationResponse(
form=FormResponse.model_validate(new_form),
message="Form generated successfully"
)
except Exception as e:
db.rollback()
logger.error(f"Form generation failed: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Form generation failed: {str(e)}"
)
@router.get("", response_model=List[FormResponse])
async def list_forms(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
limit: int = 50,
offset: int = 0,
sort: str = "created_at"
):
"""Get all forms for the current user, optionally sorted by created_at or updated_at"""
query = db.query(Form).filter(Form.user_id == current_user.id)
# Apply sorting
if sort == "updated_at":
query = query.order_by(desc(Form.updated_at))
elif sort == "created_at":
query = query.order_by(desc(Form.created_at))
else:
# Default to created_at if invalid sort parameter
query = query.order_by(desc(Form.created_at))
forms = query.limit(limit).offset(offset).all()
return [FormResponse.model_validate(form) for form in forms]
@router.get("/{form_id}", response_model=FormResponse)
async def get_form(
form_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get a specific form with all questions and rules"""
form = db.query(Form).filter(
Form.id == form_id,
Form.user_id == current_user.id
).first()
if not form:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Form not found"
)
return FormResponse.model_validate(form)
@router.put("/{form_id}", response_model=FormResponse)
async def update_form(
form_id: int,
form_update: FormUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Update form metadata (title, description, settings)"""
form = db.query(Form).filter(
Form.id == form_id,
Form.user_id == current_user.id
).first()
if not form:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Form not found"
)
# Update fields
if form_update.title is not None:
form.title = form_update.title
if form_update.description is not None:
form.description = form_update.description
if form_update.settings is not None:
form.settings = form_update.settings.model_dump()
form.updated_at = datetime.utcnow()
db.commit()
db.refresh(form)
return FormResponse.model_validate(form)
@router.delete("/{form_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_form(
form_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Delete a form and all associated data"""
form = db.query(Form).filter(
Form.id == form_id,
Form.user_id == current_user.id
).first()
if not form:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Form not found"
)
# Delete all related records in order to respect foreign key constraints
# 1. Delete chat messages
db.query(ChatMessageModel).filter(ChatMessageModel.form_id == form_id).delete(synchronize_session=False)
# 2. Get response IDs first (needed for cascading deletes)
response_ids = [r.id for r in db.query(FormResponseModel.id).filter(FormResponseModel.form_id == form_id).all()]
if response_ids:
# 2a. Delete form uploads (linked to responses)
db.query(FormUpload).filter(FormUpload.form_response_id.in_(response_ids)).delete(synchronize_session=False)
# 2b. Delete response answers (linked to responses)
db.query(ResponseAnswer).filter(ResponseAnswer.form_response_id.in_(response_ids)).delete(synchronize_session=False)
# 3. Delete form responses
db.query(FormResponseModel).filter(FormResponseModel.form_id == form_id).delete(synchronize_session=False)
# 4. Delete public forms (share links)
db.query(PublicForm).filter(PublicForm.form_id == form_id).delete(synchronize_session=False)
# 5. Delete conditional rules (linked to the form)
db.query(ConditionalRule).filter(ConditionalRule.form_id == form_id).delete(synchronize_session=False)
# 6. Delete form questions
db.query(FormQuestion).filter(FormQuestion.form_id == form_id).delete(synchronize_session=False)
# 7. Finally delete the form
db.delete(form)
db.commit()
logger.info(f"Deleted form {form_id} and all associated data for user {current_user.id}")
return None
# Question endpoints
@router.post("/{form_id}/questions", response_model=QuestionResponse)
async def add_question(
form_id: int,
question_data: QuestionCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Add a new question to a form"""
form = db.query(Form).filter(
Form.id == form_id,
Form.user_id == current_user.id
).first()
if not form:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Form not found"
)
question = FormQuestion(
form_id=form_id,
question_order=question_data.question_order,
question_type=question_data.question_type,
question_text=question_data.question_text,
description=question_data.description,
required=question_data.required,
settings=question_data.settings.model_dump() if question_data.settings else {}
)
db.add(question)
db.commit()
db.refresh(question)
return QuestionResponse.model_validate(question)
@router.put("/{form_id}/questions/{question_id}", response_model=QuestionResponse)
async def update_question(
form_id: int,
question_id: int,
question_update: QuestionUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Update a question"""
form = db.query(Form).filter(
Form.id == form_id,
Form.user_id == current_user.id
).first()
if not form:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Form not found"
)
question = db.query(FormQuestion).filter(
FormQuestion.id == question_id,
FormQuestion.form_id == form_id
).first()
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found"
)
# Update fields
if question_update.question_text is not None:
question.question_text = question_update.question_text
if question_update.question_type is not None:
question.question_type = question_update.question_type
if question_update.description is not None:
question.description = question_update.description
if question_update.required is not None:
question.required = question_update.required
if question_update.question_order is not None:
question.question_order = question_update.question_order
if question_update.settings is not None:
question.settings = question_update.settings.model_dump()
question.updated_at = datetime.utcnow()
db.commit()
db.refresh(question)
return QuestionResponse.model_validate(question)
@router.delete("/{form_id}/questions/{question_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_question(
form_id: int,
question_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Delete a question"""
form = db.query(Form).filter(
Form.id == form_id,
Form.user_id == current_user.id
).first()
if not form:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Form not found"
)
question = db.query(FormQuestion).filter(
FormQuestion.id == question_id,
FormQuestion.form_id == form_id
).first()
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found"
)
db.delete(question)
db.commit()
return None
@router.post("/{form_id}/questions/{question_id}/regenerate")
async def regenerate_question(
form_id: int,
question_id: int,
context: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Regenerate/edit a question using AI based on user's prompt.
"""
# Get the form
form = db.query(Form).filter(
Form.id == form_id,
Form.user_id == current_user.id
).first()
if not form:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Form not found"
)
# Get the question
question = db.query(FormQuestion).filter(
FormQuestion.id == question_id,
FormQuestion.form_id == form_id
).first()
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found"
)
try:
# Get user's edit prompt
user_prompt = context.get("prompt", context.get("context", "Improve this question"))
# Import the signature for question editing
from ..services.agents import QuestionEditorSignature
import dspy
import json
# Prepare form context
form_context = {
"title": form.title,
"description": form.description,
"existing_questions": [q.question_text for q in form.questions if q.id != question_id]
}
# Generate new question spec using the editor signature
editor = dspy.Predict(QuestionEditorSignature)
result = editor(
current_question_type=str(question.question_type.value),
current_question_text=question.question_text,
current_description=question.description or "",
current_settings=json.dumps(question.settings) if question.settings else "{}",
current_required=str(question.required).lower(),
user_edit_prompt=user_prompt,
form_context=json.dumps(form_context)
)
# Parse and validate the result
question_spec = json.loads(result.question_spec)
# Update the question
question.question_text = question_spec.get("question_text", question.question_text)
question.description = question_spec.get("description")
question.required = question_spec.get("required", question.required)
# Update question type if specified
if "question_type" in question_spec:
try:
question.question_type = QuestionType(question_spec["question_type"])
except ValueError:
pass # Keep existing type if invalid
# Update settings if provided
if "settings" in question_spec:
question.settings = question_spec["settings"]
db.commit()
db.refresh(question)
return {"question": QuestionResponse.model_validate(question), "message": "Question updated successfully"}
except Exception as e:
logger.error(f"Question regeneration error: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to regenerate question: {str(e)}"
)
# Conditional logic endpoints
@router.post("/{form_id}/conditional", response_model=ConditionalRuleResponse)
async def add_conditional_rule(
form_id: int,
rule_data: ConditionalRuleCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Add a conditional logic rule"""
form = db.query(Form).filter(
Form.id == form_id,
Form.user_id == current_user.id
).first()
if not form:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Form not found"
)
rule = ConditionalRule(
form_id=form_id,
trigger_question_id=rule_data.trigger_question_id,
target_question_id=rule_data.target_question_id,
condition_type=rule_data.condition_type,
condition_value=rule_data.condition_value,
action=rule_data.action
)
db.add(rule)
db.commit()
db.refresh(rule)
return ConditionalRuleResponse.model_validate(rule)
@router.delete("/{form_id}/conditional/{rule_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_conditional_rule(
form_id: int,
rule_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Delete a conditional logic rule"""
form = db.query(Form).filter(
Form.id == form_id,
Form.user_id == current_user.id
).first()
if not form:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Form not found"
)
rule = db.query(ConditionalRule).filter(
ConditionalRule.id == rule_id,
ConditionalRule.form_id == form_id
).first()
if not rule:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Rule not found"
)
db.delete(rule)
db.commit()
return None
# Public publishing endpoint
@router.post("/{form_id}/publish", response_model=PublicFormResponse)
async def publish_form(
form_id: int,
share_data: PublicFormCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create or update a public publishable link for a form"""
from ..services.og_image_service import og_image_service
form = db.query(Form).filter(
Form.id == form_id,
Form.user_id == current_user.id
).first()
if not form:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Form not found"
)
# Check if public form already exists
existing = db.query(PublicForm).filter(
PublicForm.form_id == form_id
).first()
# Generate OG image for social sharing
question_count = len(form.questions) if form.questions else 0
logger.info(f"Generating OG image for form {form_id}, title: {form.title}, questions: {question_count}")
og_image_url = og_image_service.generate_and_upload(
form_id=form.id,
token=existing.share_token if existing else "temp",
title=form.title or "Untitled Form",
description=form.description,
background_color=form.settings.get('background_color', '#ffffff') if form.settings else '#ffffff',
accent_color=form.settings.get('accent_color', '#9333ea') if form.settings else '#9333ea',
text_color=form.settings.get('text_color') if form.settings else None,
question_count=question_count
)
logger.info(f"OG image result for form {form_id}: {og_image_url}")
if existing:
# Update the OG image URL (in case form title/styling changed)
existing.og_image_url = og_image_url
existing.updated_at = datetime.utcnow()
db.commit()
db.refresh(existing)
return PublicFormResponse.model_validate(existing)
# Generate unique publish token
share_token = secrets.token_urlsafe(32)
# Re-generate OG image with actual token
if og_image_url:
og_image_url = og_image_service.generate_and_upload(
form_id=form.id,
token=share_token,
title=form.title or "Untitled Form",
description=form.description,
background_color=form.settings.get('background_color', '#ffffff') if form.settings else '#ffffff',
accent_color=form.settings.get('accent_color', '#9333ea') if form.settings else '#9333ea',
text_color=form.settings.get('text_color') if form.settings else None,
question_count=question_count
)
public_form = PublicForm(
form_id=form_id,
user_id=current_user.id,
share_token=share_token,
is_public=True,
expires_at=share_data.expires_at,
allow_multiple_submissions=share_data.allow_multiple_submissions,
collect_email=share_data.collect_email,
custom_thank_you_message=share_data.custom_thank_you_message,
og_image_url=og_image_url
)
db.add(public_form)
db.commit()
db.refresh(public_form)
return PublicFormResponse.model_validate(public_form)
@router.get("/{form_id}/publish", response_model=PublicFormResponse)
async def get_publish_info(
form_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get public publish information for a form"""
form = db.query(Form).filter(
Form.id == form_id,
Form.user_id == current_user.id
).first()
if not form:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Form not found"
)
public_form = db.query(PublicForm).filter(
PublicForm.form_id == form_id
).first()
if not public_form:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Form is not shared"
)
return PublicFormResponse.model_validate(public_form)
# Streaming chat endpoint with SSE - MUST be before /{form_id}/chat to match correctly
@router.post("/{form_id}/chat/stream")
async def chat_stream(
form_id: int,
chat_data: ChatMessageSchema,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Streaming chat endpoint using Server-Sent Events (SSE).
Streams: route -> content chunks -> data -> done
"""
from fastapi.responses import StreamingResponse
from sqlalchemy import func
import json
import asyncio
from ..services.response_chat_service import response_chat_service
from ..services.agents import FormChatFunction
from sqlalchemy.orm import joinedload
# Verify form ownership - eagerly load questions to avoid DetachedInstanceError in generator
form = db.query(Form).options(
joinedload(Form.questions)
).filter(
Form.id == form_id,
Form.user_id == current_user.id
).first()
if not form:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Form not found"
)
# Get response count BEFORE entering generator (session available here)
response_count = db.query(func.count(FormResponseModel.id)).filter(
FormResponseModel.form_id == form_id
).scalar() or 0
# Pre-load form structure BEFORE entering generator to avoid session issues
current_form_structure = {
"title": form.title,
"description": form.description,
"questions": [
{
"id": q.id,
"question_order": q.question_order,
"question_type": q.question_type.value,
"question_text": q.question_text,
"description": q.description,
"required": q.required,
"settings": q.settings
}
for q in form.questions
],
"settings": form.settings,
"response_count": response_count
}
# Pre-load response data if available
response_data_preloaded = None
conn_preloaded = None
columns_preloaded = None
duckdb_ready = False
duckdb_error = None
if response_count > 0:
try:
conn_preloaded, columns_preloaded, question_id_to_col = response_chat_service.load_responses_to_duckdb(
db=db,
form_id=form_id
)
# Validate DuckDB is actually ready
is_ready, validation_error = response_chat_service.validate_duckdb_ready(
conn_preloaded,
columns_preloaded
)
if is_ready:
response_data_preloaded = {
'schema_description': response_chat_service.get_schema_description(
columns=columns_preloaded,
question_id_to_col=question_id_to_col,
db=db,
form_id=form_id
),
'summary': response_chat_service.get_response_summary(conn_preloaded, columns_preloaded),
'columns': columns_preloaded,
'conn': conn_preloaded
}
duckdb_ready = True
logger.info(f"DuckDB ready for form {form_id} with {response_count} responses")
else:
duckdb_error = validation_error
logger.warning(f"DuckDB validation failed for form {form_id}: {validation_error}")
except Exception as e:
duckdb_error = str(e)
logger.warning(f"Could not load responses for form {form_id}: {e}")
# Capture user ID as plain int to avoid session detachment issues
user_id_for_save = current_user.id
# Determine chat type based on whether this is likely a response analysis
chat_type = "form_editing" # Default
# Save user message to database
user_chat_message = ChatMessageModel(
form_id=form_id,
user_id=user_id_for_save,
chat_type=chat_type,
role="user",
content=chat_data.message
)
db.add(user_chat_message)
db.commit()
# Variables to collect response for saving
collected_response = {"content": "", "route": "", "query_type": None, "sql_query": None, "result_data": None}
async def event_generator():
nonlocal collected_response
conn = conn_preloaded
response_data = response_data_preloaded
assistant_content_parts = [] # Collect all content chunks
try:
# Initialize chat module
chat_module = FormChatFunction()
# Process with chat module
result = await chat_module.aforward(
user_query=chat_data.message,
form_context=json.dumps(current_form_structure),
current_form=current_form_structure,
response_count=response_count,
response_data=response_data
)
route = result.get('route', 'unknown')
collected_response['route'] = route
# Update chat type based on route
if route == 'analyze_responses':
collected_response['query_type'] = 'response_analysis'
elif route == 'add_component':
collected_response['query_type'] = 'add_component'
elif route == 'edit_component':
collected_response['query_type'] = 'edit_component'
else:
collected_response['query_type'] = route
# Send route event
yield f"event: route\ndata: {json.dumps({'route': route})}\n\n"
await asyncio.sleep(0.01)
# Handle response analysis with data
if result.get('requires_response_analysis') and response_count > 0:
# Check if DuckDB failed to load (agent already provided error message)
if result.get('duckdb_ready') == False:
assistant_response = result.get('response', "Unable to analyze responses - data not loaded.")
assistant_content_parts.append(assistant_response)
yield f"event: content\ndata: {json.dumps({'content': assistant_response})}\n\n"
collected_response['content'] = assistant_response
yield f"event: done\ndata: {json.dumps({'done': True})}\n\n"
return
sql_query = result.get('sql_query')
assistant_response = result.get('response')
result_data = None
# Execute SQL if generated (double-check conn is valid)
if sql_query and conn and duckdb_ready:
try:
query_results, result_columns = response_chat_service.execute_query(conn, sql_query)
result_data = {
"columns": result_columns,
"rows": query_results[:50],
"row_count": len(query_results)
}
# Generate summary
assistant_response = chat_module.summarize_query_results(
query_results=query_results,
user_query=chat_data.message,
schema_description=response_data['schema_description']
)
except ValueError as e:
assistant_response = f"I couldn't execute that query: {str(e)}. Try rephrasing?"
# Default summary if no response yet
if not assistant_response and response_data:
summary = response_data.get('summary', {})
assistant_response = f"Based on {response_count} responses:\n\n"
assistant_response += f"**Total:** {summary.get('total_responses', 0)}\n"
status_breakdown = summary.get('status_breakdown', {})
if status_breakdown:
assistant_response += f"**Status:** {', '.join([f'{k}: {v}' for k, v in status_breakdown.items()])}"
# Stream content in chunks
if assistant_response:
assistant_content_parts.append(assistant_response)
words = assistant_response.split(' ')
chunk_size = 5
for i in range(0, len(words), chunk_size):
chunk = ' '.join(words[i:i+chunk_size])
if i > 0:
chunk = ' ' + chunk
yield f"event: content\ndata: {json.dumps({'content': chunk})}\n\n"
await asyncio.sleep(0.02)
# Send data if available
if result_data:
collected_response['result_data'] = result_data
yield f"event: data\ndata: {json.dumps(result_data)}\n\n"
# Store SQL query if used
if sql_query:
collected_response['sql_query'] = sql_query
# Handle add_component
elif route == 'add_component':
response = result.get('response')
if hasattr(response, 'component_spec'):
try:
raw_spec = response.component_spec
if isinstance(raw_spec, str):
raw_spec = repair_json(raw_spec)
component_spec = json.loads(raw_spec)
else:
component_spec = raw_spec
# Create question in DB
max_order = db.query(func.max(FormQuestion.question_order)).filter(
FormQuestion.form_id == form_id
).scalar() or -1
new_question = FormQuestion(
form_id=form_id,
question_order=max_order + 1,
question_type=QuestionType(component_spec.get("question_type", "short_answer")),
question_text=component_spec.get("question_text", "New Question"),
description=component_spec.get("description"),
required=component_spec.get("required", False),
settings=component_spec.get("settings", {})
)
db.add(new_question)
db.commit()
db.refresh(new_question)
msg = f"Added new **{component_spec.get('question_type', 'question')}** field: {component_spec.get('question_text', 'New Question')}"
assistant_content_parts.append(msg)
yield f"event: content\ndata: {json.dumps({'content': msg})}\n\n"
yield f"event: form_updated\ndata: {json.dumps({'action': 'add', 'question_id': new_question.id})}\n\n"
except Exception as e:
error_msg = f'Error adding component: {str(e)}'
assistant_content_parts.append(error_msg)
yield f"event: content\ndata: {json.dumps({'content': error_msg})}\n\n"
# Handle edit_component
elif route == 'edit_component':
response = result.get('response')
if hasattr(response, 'updated_form'):
try:
raw_form = response.updated_form
if isinstance(raw_form, str):
raw_form = repair_json(raw_form)
updated_form = json.loads(raw_form)
else:
updated_form = raw_form
changes_desc = getattr(response, 'changes_made', 'Form updated')
if updated_form and "components" in updated_form:
for component in updated_form.get("components", []):
comp_id = component.get("component_id")
if comp_id and comp_id.startswith("comp_"):
try:
order_idx = int(comp_id.split("_")[1]) - 1
question = db.query(FormQuestion).filter(
FormQuestion.form_id == form_id,
FormQuestion.question_order == order_idx
).first()
if question:
if "question_text" in component:
question.question_text = component["question_text"]
if "question_type" in component:
question.question_type = QuestionType(component["question_type"])
if "description" in component:
question.description = component["description"]
if "required" in component:
question.required = component["required"]
if "settings" in component:
question.settings = component["settings"]
question.updated_at = datetime.utcnow()
except (ValueError, IndexError):
continue
db.commit()
assistant_content_parts.append(str(changes_desc))
yield f"event: content\ndata: {json.dumps({'content': str(changes_desc)})}\n\n"
yield f"event: form_updated\ndata: {json.dumps({'action': 'edit'})}\n\n"
except Exception as e:
error_msg = f'Error editing: {str(e)}'
assistant_content_parts.append(error_msg)
yield f"event: content\ndata: {json.dumps({'content': error_msg})}\n\n"
# Handle no_responses
elif route == 'no_responses':
response = result.get('response', '')
assistant_content_parts.append(response)
yield f"event: content\ndata: {json.dumps({'content': response})}\n\n"
# Handle general query or other routes
else:
response = result.get('response')
if response:
if hasattr(response, 'answer'):
msg = response.answer
elif isinstance(response, dict) and 'message' in response:
msg = response['message']
elif isinstance(response, str):
msg = response
else:
msg = str(response)
assistant_content_parts.append(msg)
# Stream in chunks
words = msg.split(' ')
chunk_size = 5
for i in range(0, len(words), chunk_size):
chunk = ' '.join(words[i:i+chunk_size])
if i > 0:
chunk = ' ' + chunk
yield f"event: content\ndata: {json.dumps({'content': chunk})}\n\n"
await asyncio.sleep(0.02)
# Collect final content
collected_response['content'] = ' '.join(assistant_content_parts)
# Send done event
yield f"event: done\ndata: {json.dumps({'status': 'complete'})}\n\n"
except Exception as e:
logger.error(f"Stream error: {e}", exc_info=True)
collected_response['content'] = f"Error: {str(e)}"
yield f"event: error\ndata: {json.dumps({'error': str(e)})}\n\n"
# Note: Don't close conn - it's cached for session reuse
def save_assistant_message_sync(form_id_val: int, user_id_val: int, response_dict: dict):
"""Save assistant message in a completely isolated session"""
from ..core.db import SessionLocal
session = None
try:
session = SessionLocal()
if response_dict.get('content'):
msg = ChatMessageModel(
form_id=form_id_val,
user_id=user_id_val,
chat_type="response_analysis" if response_dict.get('route') == 'analyze_responses' else "form_editing",
role="assistant",
content=response_dict.get('content', ''),
query_type=response_dict.get('query_type'),
sql_query=response_dict.get('sql_query'),
result_data=response_dict.get('result_data')
)
session.add(msg)
session.commit()
logger.debug(f"Saved assistant message for form {form_id_val}")
except Exception as e:
logger.error(f"Failed to save chat message: {e}")
if session:
session.rollback()
finally:
if session:
session.close()
async def streaming_with_save():
"""Wrap generator to save message after completion"""
async for event in event_generator():
yield event
# Save after streaming completes - pass values explicitly to avoid closure issues
save_assistant_message_sync(form_id, user_id_for_save, collected_response.copy())
return StreamingResponse(
streaming_with_save(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
# Chat editing endpoint
@router.post("/{form_id}/chat", response_model=ChatResponse)
async def chat_edit_form(
form_id: int,
chat_data: ChatMessageSchema,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Unified chat endpoint for form editing AND response analysis.
Uses GPT-4o-mini for routing, then delegates to appropriate handlers.
"""
from sqlalchemy import func
import json
from ..models import FormResponse as FormResponseModel
from ..services.response_chat_service import response_chat_service
from ..services.agents import FormChatFunction
# Get the form
form = db.query(Form).filter(
Form.id == form_id,
Form.user_id == current_user.id
).first()
if not form:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Form not found"
)
try:
# Get response count for context
response_count = db.query(func.count(FormResponseModel.id)).filter(
FormResponseModel.form_id == form_id
).scalar() or 0
# Prepare current form structure
current_form_structure = {
"title": form.title,
"description": form.description,
"questions": [
{
"id": q.id,
"question_order": q.question_order,
"question_type": q.question_type.value,
"question_text": q.question_text,
"description": q.description,
"required": q.required,
"settings": q.settings
}
for q in form.questions
],
"settings": form.settings,
"response_count": response_count
}
# Initialize chat module
chat_module = FormChatFunction()
# Prepare response data if there are responses
response_data = None
conn = None
if response_count > 0:
try:
conn, columns, question_id_to_col = response_chat_service.load_responses_to_duckdb(
db=db,
form_id=form_id
)
response_data = {
'schema_description': response_chat_service.get_schema_description(
columns=columns,
question_id_to_col=question_id_to_col,
db=db,
form_id=form_id
),
'summary': response_chat_service.get_response_summary(conn, columns),
'columns': columns,
'conn': conn
}
except Exception as e:
logger.warning(f"Could not load responses: {e}")
# Process with unified module
result = await chat_module.aforward(
user_query=chat_data.message,
form_context=json.dumps(current_form_structure),
current_form=current_form_structure,
response_count=response_count,
response_data=response_data
)
route = result.get('route', 'unknown')
response = result.get('response')
# === Handle Response Analysis ===
if result.get('requires_response_analysis') and response_count > 0:
try:
sql_query = result.get('sql_query')
assistant_response = response
result_data = None
# Execute SQL if generated
if sql_query and conn:
try:
query_results, result_columns = response_chat_service.execute_query(conn, sql_query)
result_data = {
"columns": result_columns,
"rows": query_results[:50],
"row_count": len(query_results)
}
# Summarize results
assistant_response = chat_module.summarize_query_results(
query_results=query_results,
user_query=chat_data.message,
schema_description=response_data['schema_description']
)
except ValueError as e:
assistant_response = f"I couldn't execute that query: {str(e)}. Try rephrasing?"
# Default summary if no response yet
if not assistant_response and response_data:
summary = response_data.get('summary', {})
assistant_response = f"Based on {response_count} responses:\n\n"
assistant_response += f"**Total:** {summary.get('total_responses', 0)}\n"
status_breakdown = summary.get('status_breakdown', {})
if status_breakdown:
assistant_response += f"**Status:** {', '.join([f'{k}: {v}' for k, v in status_breakdown.items()])}"
# Note: Don't close conn - it's cached for session reuse
return ChatResponse(
route="analyze_responses",
response={
"message": assistant_response or "Analysis complete.",
"data": result_data
},
changes_made=f"Analyzed {response_count} responses"
)
except Exception as e:
logger.error(f"Response analysis failed: {e}", exc_info=True)
# Note: Don't close conn - it's cached for session reuse
return ChatResponse(
route="analyze_responses",
response={"message": f"Error analyzing responses: {str(e)}"},
changes_made=None
)
# Note: Don't close conn - it's cached for session reuse
# === Handle No Responses ===
if route == 'no_responses':
return ChatResponse(
route="no_responses",
response={"message": response},
changes_made=None
)
# If route is add_component, add the new component
if route == "add_component" and response:
# Extract component spec from response
component_spec = None
if hasattr(response, 'component_spec'):
try:
raw_spec = response.component_spec
if isinstance(raw_spec, str):
raw_spec = repair_json(raw_spec)
component_spec = json.loads(raw_spec)
else:
component_spec = raw_spec
except Exception as e:
logger.error(f"Failed to parse component_spec: {e}")
component_spec = None
if component_spec:
# Get the next question order
max_order = db.query(func.max(FormQuestion.question_order)).filter(
FormQuestion.form_id == form_id
).scalar() or -1
# Create new question
new_question = FormQuestion(
form_id=form_id,
question_order=max_order + 1,
question_type=QuestionType(component_spec.get("question_type", "short_answer")),
question_text=component_spec.get("question_text", "New Question"),
description=component_spec.get("description"),
required=component_spec.get("required", False),
settings=component_spec.get("settings", {})
)
db.add(new_question)
db.commit()
db.refresh(new_question)
return ChatResponse(
route=route,
response={
"new_question": QuestionResponse.model_validate(new_question).model_dump(),
"message": "Component added successfully"
},
changes_made=f"Added new {component_spec.get('question_type')} component"
)
# If route is edit_component, apply changes
elif route == "edit_component" and response:
# Extract updated form from response
updated_form = None
if hasattr(response, 'updated_form'):
try:
raw_form = response.updated_form
if isinstance(raw_form, str):
raw_form = repair_json(raw_form)
updated_form = json.loads(raw_form)
else:
updated_form = raw_form
except Exception as e:
logger.error(f"Failed to parse updated_form: {e}")
updated_form = None
if updated_form and "components" in updated_form:
# Apply updates to existing questions
for component in updated_form.get("components", []):
comp_id = component.get("component_id")
if comp_id and comp_id.startswith("comp_"):
# Extract order from component_id (e.g., comp_1 -> 0)
try:
order_idx = int(comp_id.split("_")[1]) - 1
question = db.query(FormQuestion).filter(
FormQuestion.form_id == form_id,
FormQuestion.question_order == order_idx
).first()
if question:
if "question_text" in component:
question.question_text = component["question_text"]
if "question_type" in component:
question.question_type = QuestionType(component["question_type"])
if "description" in component:
question.description = component["description"]
if "required" in component:
question.required = component["required"]
if "settings" in component:
question.settings = component["settings"]
question.updated_at = datetime.utcnow()
except (ValueError, IndexError):
continue
db.commit()
return ChatResponse(
route=route,
response={
"message": "Form updated successfully",
"form": FormResponse.model_validate(form).model_dump()
},
changes_made=changes_made or "Form updated based on your request"
)
# For general queries or unclear requests, return the response as-is
response_data = response
if hasattr(response, 'answer'):
response_data = {"answer": response.answer}
elif hasattr(response, 'model_dump'):
response_data = response.model_dump()
elif not isinstance(response, dict):
response_data = {"message": str(response)}
return ChatResponse(
route=route,
response=response_data,
changes_made=changes_made
)
except Exception as e:
logger.error(f"Chat edit failed: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Chat processing failed: {str(e)}"
)
async def _send_feedback_email_if_eligible(user_id: int, user_email: str, user_name: str, form_count: int):
"""
Send feedback request email to eligible users (paid plans only).
Runs asynchronously and doesn't block the main request.
Creates its own database session to avoid session conflicts.
Args:
user_id: User ID
user_email: User email address
user_name: User name
form_count: Number of forms the user has created
"""
from ..core.db import SessionLocal
# Create a new database session for this async task
db = SessionLocal()
try:
# Get user's subscription
subscription = db.query(Subscription).filter(
Subscription.user_id == user_id
).order_by(desc(Subscription.created_at)).first()
# Only send to users with paid plans (plan_id > 1, as 1 is free tier)
if not subscription or not subscription.plan_id or subscription.plan_id <= 1:
logger.info(f"User {user_email} is on free tier (plan_id: {subscription.plan_id if subscription else 'None'}), skipping feedback email")
return
logger.info(f"Attempting to send feedback email to {user_email} (plan_id: {subscription.plan_id})")
# Send feedback request email
await email_service.send_feedback_request_email(
to_email=user_email,
user_name=user_name,
form_count=form_count
)
logger.info(f"✅ Feedback email sent successfully to {user_email}")
except EmailServiceError as e:
# Log but don't fail the request
logger.error(f"Failed to send feedback email to {user_email}: {e}")
except Exception as e:
# Catch all other errors to prevent breaking the main flow
logger.error(f"Unexpected error sending feedback email to {user_email}: {e}", exc_info=True)
finally:
# Always close the database session
db.close()