Spaces:
Runtime error
Runtime error
| """ | |
| 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"]) | |
| 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)}" | |
| ) | |
| 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] | |
| 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) | |
| 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) | |
| 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 | |
| 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) | |
| 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) | |
| 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 | |
| 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 | |
| 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) | |
| 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 | |
| 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) | |
| 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 | |
| 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 | |
| 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() | |