diff --git a/src/backend/clara/agents/design_assistant.py b/src/backend/clara/agents/design_assistant.py index 24b7786..97f5cf7 100644 --- a/src/backend/clara/agents/design_assistant.py +++ b/src/backend/clara/agents/design_assistant.py @@ -265,11 +265,13 @@ def _create_subagents(self) -> dict[str, AgentDefinition]: "First call mcp__clara__get_prompt to get hydrated instructions. " "This agent MUST use mcp__clara__ask to collect user input before building. " "It should NOT build entities/agents until user confirms via ask tool responses. " - "Use mcp__clara__prompt_editor to show generated prompts for user editing." + "Use mcp__clara__prompt_editor to show generated prompts for user editing. " + "Use mcp__clara__get_agent_context to access uploaded context files." ), tools=["mcp__clara__project", "mcp__clara__entity", "mcp__clara__agent", "mcp__clara__ask", "mcp__clara__preview", "mcp__clara__phase", - "mcp__clara__get_prompt", "mcp__clara__prompt_editor"], + "mcp__clara__get_prompt", "mcp__clara__prompt_editor", + "mcp__clara__get_agent_context"], prompt=phase3_prompt, model="sonnet" ), @@ -584,12 +586,48 @@ def __init__(self): async def get_or_create_session( self, session_id: str, - project_id: str + project_id: str, + initial_blueprint_state: dict | None = None ) -> DesignAssistantSession: - """Get an existing session or create a new one.""" + """Get an existing session or create a new one. + + Args: + session_id: The session ID + project_id: The project ID + initial_blueprint_state: Optional blueprint state to initialize with + (used for add-agent mode to preserve existing agents) + """ if session_id not in self._sessions: session = DesignAssistantSession(session_id, project_id) await session.start() + + # If initial blueprint state provided, populate the tools state + if initial_blueprint_state: + tool_state = get_session_state(session_id) + tool_state["project"] = initial_blueprint_state.get("project") + tool_state["entities"] = initial_blueprint_state.get("entities", []) + tool_state["agents"] = initial_blueprint_state.get("agents", []) + tool_state["phase"] = DesignPhase.AGENT_CONFIGURATION.value + + # Update session state to reflect the blueprint + if initial_blueprint_state.get("project"): + proj = initial_blueprint_state["project"] + session.state.blueprint_preview.project_name = proj.get("name") + session.state.blueprint_preview.project_type = proj.get("type") + session.state.inferred_domain = proj.get("domain") + session.state.blueprint_preview.agent_count = len( + initial_blueprint_state.get("agents", []) + ) + session.state.blueprint_preview.entity_types = [ + e.get("name") for e in initial_blueprint_state.get("entities", []) + ] + session.state.phase = DesignPhase.AGENT_CONFIGURATION + + logger.info( + f"Initialized session {session_id} with existing blueprint " + f"({len(initial_blueprint_state.get('agents', []))} agents)" + ) + self._sessions[session_id] = session return self._sessions[session_id] diff --git a/src/backend/clara/agents/simulation_agent.py b/src/backend/clara/agents/simulation_agent.py index 9fcf844..b670025 100644 --- a/src/backend/clara/agents/simulation_agent.py +++ b/src/backend/clara/agents/simulation_agent.py @@ -506,6 +506,23 @@ def extract_company_name_from_url(url: str) -> str: return "the company" +def _get_anthropic_client() -> anthropic.Anthropic: + """Get an Anthropic client using centralized configuration. + + Uses settings.anthropic_api_key if configured, otherwise falls back + to the ANTHROPIC_API_KEY environment variable. + + Raises: + ValueError: If no API key is available + """ + api_key = settings.anthropic_api_key + if api_key: + return anthropic.Anthropic(api_key=api_key) + # Let the client use ANTHROPIC_API_KEY env var (default behavior) + # This will raise AuthenticationError if not set + return anthropic.Anthropic() + + async def search_company_products(url: str, role: str | None = None) -> str: """Use web search to gather information about a company's products. @@ -535,7 +552,7 @@ async def search_company_products(url: str, role: str | None = None) -> str: logger.info(f"Searching for company products: {search_query}") try: - client = anthropic.Anthropic() + client = _get_anthropic_client() # Build the research prompt focus_item = "" @@ -554,9 +571,9 @@ async def search_company_products(url: str, role: str | None = None) -> str: Provide a detailed summary that would help someone understand what it's like \ to work at this company in a product/technical role.""" - # Use Claude with web search tool + # Use Claude with web search tool (model from config) response = client.messages.create( - model="claude-sonnet-4-20250514", + model=settings.web_search_model, max_tokens=2048, tools=[{"type": "web_search_20250305", "name": "web_search"}], messages=[{"role": "user", "content": research_prompt}], @@ -569,17 +586,22 @@ async def search_company_products(url: str, role: str | None = None) -> str: result_text += block.text if result_text: - logger.info(f"Successfully gathered company context via web search for {company_name}") + logger.info( + f"Successfully gathered company context via web search for {company_name}" + ) return result_text return f"(Could not find detailed product information for {company_name})" + except anthropic.AuthenticationError as e: + logger.error(f"Anthropic authentication failed: {e}") + return "(Web search unavailable: API key not configured)" except anthropic.APIError as e: logger.warning(f"Anthropic API error during web search: {e}") - return f"(Web search unavailable: {e})" + return "(Web search temporarily unavailable)" except Exception as e: - logger.warning(f"Error during company product search: {e}") - return f"(Could not search for company products: {e})" + logger.exception(f"Unexpected error during company product search: {type(e).__name__}") + return "(Could not search for company products)" async def gather_company_context(url: str, role: str | None = None) -> str: diff --git a/src/backend/clara/agents/tools.py b/src/backend/clara/agents/tools.py index 73cb1ea..2a10bab 100644 --- a/src/backend/clara/agents/tools.py +++ b/src/backend/clara/agents/tools.py @@ -312,6 +312,17 @@ def cleanup_stale_sessions() -> int: "required": ["title", "prompt"], } +GetAgentContextSchema = { + "type": "object", + "properties": { + "agent_index": { + "type": "integer", + "description": "Index of the agent to get context files for (0-based)", + }, + }, + "required": ["agent_index"], +} + def create_clara_tools(session_id: str): """Create MCP tools bound to a specific session. @@ -508,12 +519,26 @@ async def preview_tool(args: dict) -> dict[str, Any]: }, } + # Add context files info for each agent + agents = state.get("agents", []) + total_context_files = 0 + for agent in agents: + context_files = agent.get("context_files", []) + if context_files: + blueprint["interview_agent"]["context_files"] = [ + {"id": f.get("id"), "name": f.get("name"), "type": f.get("type")} + for f in context_files + ] + total_context_files += len(context_files) + summary_parts = [] if project.get("name"): summary_parts.append(f"Project: {project['name']}") summary_parts.append(f"Knowledge Areas: {len(state['entities'])}") if agent_caps.get("role"): summary_parts.append(f"Interviewer: {agent_caps['role']}") + if total_context_files > 0: + summary_parts.append(f"Context Files: {total_context_files}") blueprint_json = json.dumps(blueprint, indent=2) summary = ", ".join(summary_parts) @@ -810,6 +835,99 @@ async def prompt_editor_tool(args: dict) -> dict[str, Any]: }] } + @tool( + "get_agent_context", + "Get uploaded context files content for an interview agent", + GetAgentContextSchema + ) + async def get_agent_context_tool(args: dict) -> dict[str, Any]: + """Retrieve the extracted text from context files uploaded for an agent. + + This allows the interview agent's system prompt to reference uploaded + documents like organization charts, process docs, or policy files. + """ + from sqlalchemy import select + + from clara.db.models import AgentContextFile + from clara.db.session import async_session_maker + + state = get_session_state(session_id) + agent_index = args["agent_index"] + + # Get agents list and validate index + agents = state.get("agents", []) + if agent_index < 0 or agent_index >= len(agents): + msg = f"Error: Invalid agent index {agent_index}. " + msg += f"Only {len(agents)} agents configured." + return { + "content": [{"type": "text", "text": msg}], + "isError": True + } + + agent = agents[agent_index] + agent_name = agent.get("name", f"Agent {agent_index}") + context_files_meta = agent.get("context_files", []) + + if not context_files_meta: + return { + "content": [{ + "type": "text", + "text": f"No context files uploaded for agent '{agent_name}'" + }] + } + + # Fetch extracted text from database + file_ids = [f["id"] for f in context_files_meta if f.get("id")] + + try: + async with async_session_maker() as db: + result = await db.execute( + select(AgentContextFile) + .where(AgentContextFile.id.in_(file_ids)) + .where(AgentContextFile.deleted_at.is_(None)) + ) + files = result.scalars().all() + + context_parts = [] + for f in files: + if f.extracted_text and f.extraction_status == "success": + context_parts.append( + f"## {f.original_filename}\n\n{f.extracted_text}" + ) + elif f.extraction_status == "partial": + context_parts.append( + f"## {f.original_filename} (truncated)\n\n{f.extracted_text}" + ) + else: + context_parts.append( + f"## {f.original_filename}\n\n[Content could not be extracted]" + ) + + combined = "\n\n---\n\n".join(context_parts) if context_parts else "" + + logger.info( + f"[{session_id}] Fetched {len(files)} context files " + f"for agent {agent_index}" + ) + + if combined: + result_text = f"Context files for agent '{agent_name}':" + result_text += f"\n\n{combined}" + else: + result_text = "No extractable content in uploaded files." + + return {"content": [{"type": "text", "text": result_text}]} + + except Exception as e: + logger.warning(f"[{session_id}] Failed to fetch context files: {e}") + return { + "content": [{ + "type": "text", + "text": f"Error fetching context files: {str(e)}" + }], + "isError": True + } + # Create the MCP server with all tools return create_sdk_mcp_server( name="clara", @@ -828,6 +946,7 @@ async def prompt_editor_tool(args: dict) -> dict[str, Any]: hydrate_phase3_tool, get_hydrated_prompt_tool, prompt_editor_tool, + get_agent_context_tool, ], ) @@ -847,4 +966,5 @@ async def prompt_editor_tool(args: dict) -> dict[str, Any]: "mcp__clara__hydrate_phase3", "mcp__clara__get_prompt", "mcp__clara__prompt_editor", + "mcp__clara__get_agent_context", ] diff --git a/src/backend/clara/api/context_files.py b/src/backend/clara/api/context_files.py new file mode 100644 index 0000000..085bf6d --- /dev/null +++ b/src/backend/clara/api/context_files.py @@ -0,0 +1,287 @@ +"""Context Files API endpoints. + +Provides file upload/download/delete endpoints for agent context files. +Files are sandboxed per project and validated for security. +""" + +import logging +from datetime import UTC, datetime + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from clara.config import settings +from clara.db.models import AgentContextFile, ContextFileStatus, DesignSession +from clara.db.session import get_db +from clara.services.file_service import FileUploadService + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/context-files", tags=["context-files"]) + +# Instantiate upload service +file_service = FileUploadService() + + +class ContextFileResponse(BaseModel): + """Response model for a context file.""" + id: str + name: str # original filename + type: str # mime type + size: int + status: str + extraction_status: str | None + uploaded_at: str + + +class ContextFileListResponse(BaseModel): + """Response model for listing context files.""" + files: list[ContextFileResponse] + total: int + + +class UploadResponse(BaseModel): + """Response after uploading a file.""" + success: bool + file: ContextFileResponse | None = None + error: str | None = None + + +@router.post("/sessions/{session_id}/agents/{agent_index}/upload", response_model=UploadResponse) +async def upload_context_file( + session_id: str, + agent_index: int, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db) +) -> UploadResponse: + """Upload a context file for an agent. + + Files are validated for: + - Allowed file types (extension + content verification) + - Maximum file size + - Security (path traversal, dangerous content) + + The file content is extracted for use in agent context. + """ + # Verify session exists and get project_id + result = await db.execute( + select(DesignSession).where(DesignSession.id == session_id) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + project_id = session.project_id + + # Check agent index is valid + agents = session.blueprint_state.get("agents", []) + if agent_index < 0 or agent_index >= len(agents): + raise HTTPException( + status_code=400, + detail=f"Invalid agent index {agent_index}. Session has {len(agents)} agents." + ) + + # Check file count limit + count_result = await db.execute( + select(func.count(AgentContextFile.id)) + .where(AgentContextFile.session_id == session_id) + .where(AgentContextFile.agent_index == agent_index) + .where(AgentContextFile.deleted_at.is_(None)) + ) + current_count = count_result.scalar() or 0 + if current_count >= settings.max_files_per_agent: + raise HTTPException( + status_code=400, + detail=f"Maximum {settings.max_files_per_agent} files allowed per agent" + ) + + # Read file content + try: + content = await file.read() + except Exception: + logger.exception("Failed to read uploaded file") + return UploadResponse(success=False, error="Failed to read file") + + # Process upload + upload_result = await file_service.upload_file( + file_content=content, + filename=file.filename or "unnamed", + project_id=project_id, + agent_index=agent_index + ) + + if not upload_result.success: + return UploadResponse(success=False, error=upload_result.error_message) + + # Create database record + filename = file.filename or "" + ext = "." + filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + context_file = AgentContextFile( + id=upload_result.file_id, + session_id=session_id, + project_id=project_id, + agent_index=agent_index, + original_filename=file.filename or "unnamed", + stored_filename=upload_result.stored_filename, + file_extension=ext, + mime_type=upload_result.mime_type, + file_size=upload_result.file_size, + storage_path=upload_result.storage_path, + extracted_text=upload_result.extracted_text, + extraction_status=upload_result.extraction_status, + checksum=upload_result.checksum, + status=ContextFileStatus.READY.value, + ) + db.add(context_file) + await db.commit() + + # Also update the agent's context_files in blueprint_state + agents = list(session.blueprint_state.get("agents", [])) + if agent_index < len(agents): + agent = agents[agent_index] + context_files = agent.get("context_files", []) + context_files.append({ + "id": upload_result.file_id, + "name": file.filename or "unnamed", + "type": upload_result.mime_type, + "size": upload_result.file_size, + "uploaded_at": datetime.now(UTC).isoformat(), + }) + agent["context_files"] = context_files + agents[agent_index] = agent + session.blueprint_state = {**session.blueprint_state, "agents": agents} + await db.commit() + + return UploadResponse( + success=True, + file=ContextFileResponse( + id=upload_result.file_id, + name=file.filename or "unnamed", + type=upload_result.mime_type, + size=upload_result.file_size, + status=ContextFileStatus.READY.value, + extraction_status=upload_result.extraction_status, + uploaded_at=datetime.now(UTC).isoformat(), + ) + ) + + +@router.get("/sessions/{session_id}/agents/{agent_index}", response_model=ContextFileListResponse) +async def list_context_files( + session_id: str, + agent_index: int, + db: AsyncSession = Depends(get_db) +) -> ContextFileListResponse: + """List all context files for an agent.""" + # Verify session exists + result = await db.execute( + select(DesignSession).where(DesignSession.id == session_id) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + # Get files + result = await db.execute( + select(AgentContextFile) + .where(AgentContextFile.session_id == session_id) + .where(AgentContextFile.agent_index == agent_index) + .where(AgentContextFile.deleted_at.is_(None)) + .order_by(AgentContextFile.created_at.desc()) + ) + files = result.scalars().all() + + return ContextFileListResponse( + files=[ + ContextFileResponse( + id=f.id, + name=f.original_filename, + type=f.mime_type, + size=f.file_size, + status=f.status, + extraction_status=f.extraction_status, + uploaded_at=f.created_at.isoformat() if f.created_at else "", + ) + for f in files + ], + total=len(files) + ) + + +@router.delete("/sessions/{session_id}/agents/{agent_index}/files/{file_id}") +async def delete_context_file( + session_id: str, + agent_index: int, + file_id: str, + db: AsyncSession = Depends(get_db) +): + """Delete a context file (soft delete).""" + # Verify session exists + result = await db.execute( + select(DesignSession).where(DesignSession.id == session_id) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + # Find file + result = await db.execute( + select(AgentContextFile) + .where(AgentContextFile.id == file_id) + .where(AgentContextFile.session_id == session_id) + .where(AgentContextFile.agent_index == agent_index) + ) + context_file = result.scalar_one_or_none() + if not context_file: + raise HTTPException(status_code=404, detail="File not found") + + # Soft delete + context_file.deleted_at = datetime.now(UTC) + context_file.status = ContextFileStatus.FAILED.value + context_file.status_message = "Deleted by user" + + # Also remove from blueprint_state + agents = list(session.blueprint_state.get("agents", [])) + if agent_index < len(agents): + agent = agents[agent_index] + context_files = [ + f for f in agent.get("context_files", []) + if f.get("id") != file_id + ] + agent["context_files"] = context_files + agents[agent_index] = agent + session.blueprint_state = {**session.blueprint_state, "agents": agents} + + await db.commit() + + return {"status": "deleted", "file_id": file_id} + + +@router.get("/sessions/{session_id}/agents/{agent_index}/files/{file_id}/content") +async def get_extracted_content( + session_id: str, + agent_index: int, + file_id: str, + db: AsyncSession = Depends(get_db) +): + """Get the extracted text content of a file for agent context.""" + # Find file + result = await db.execute( + select(AgentContextFile) + .where(AgentContextFile.id == file_id) + .where(AgentContextFile.session_id == session_id) + .where(AgentContextFile.agent_index == agent_index) + .where(AgentContextFile.deleted_at.is_(None)) + ) + context_file = result.scalar_one_or_none() + if not context_file: + raise HTTPException(status_code=404, detail="File not found") + + return { + "file_id": file_id, + "filename": context_file.original_filename, + "extraction_status": context_file.extraction_status, + "content": context_file.extracted_text, + } diff --git a/src/backend/clara/api/design_sessions.py b/src/backend/clara/api/design_sessions.py index 8a144e2..eff3afc 100644 --- a/src/backend/clara/api/design_sessions.py +++ b/src/backend/clara/api/design_sessions.py @@ -28,6 +28,7 @@ class CreateSessionRequest(BaseModel): """Request to create a new design session.""" project_id: str + add_agent: bool = False # When True, creates a fresh new session for another agent class CreateSessionResponse(BaseModel): @@ -71,22 +72,30 @@ async def create_or_resume_session( If an active session exists for the project, returns that session. Otherwise creates a new session. + + If add_agent=True, always creates a fresh new session (ignores existing sessions). """ - # Check for existing active session for this project - result = await db.execute( - select(DesignSession) - .where(DesignSession.project_id == request.project_id) - .where(DesignSession.status == DesignSessionStatus.ACTIVE.value) - .order_by(DesignSession.updated_at.desc()) - .limit(1) - ) - existing_session = result.scalar_one_or_none() + # If add_agent mode, skip checking for existing session - always create fresh + if request.add_agent: + existing_session = None + else: + # Check for existing active session for this project + result = await db.execute( + select(DesignSession) + .where(DesignSession.project_id == request.project_id) + .where(DesignSession.status == DesignSessionStatus.ACTIVE.value) + .order_by(DesignSession.updated_at.desc()) + .limit(1) + ) + existing_session = result.scalar_one_or_none() if existing_session: # Resume existing session session_id = existing_session.id is_new = False - logger.info(f"Resuming existing session {session_id} for project {request.project_id}") + logger.info( + f"Resuming existing session {session_id} for project {request.project_id}" + ) # Restore in-memory state from DB try: @@ -193,6 +202,68 @@ async def get_session_by_project( ) +class ProjectAgentInfo(BaseModel): + """Agent info with session reference for project-level listing.""" + session_id: str + agent_index: int + name: str + persona: str | None + topics: list[str] + tone: str | None + system_prompt: str | None + context_files: list[dict] | None + + +class ProjectAgentsResponse(BaseModel): + """All agents for a project, aggregated from all sessions.""" + project_id: str + agents: list[ProjectAgentInfo] + session_count: int + + +@router.get("/project/{project_id}/agents", response_model=ProjectAgentsResponse) +async def get_project_agents( + project_id: str, + db: AsyncSession = Depends(get_db) +) -> ProjectAgentsResponse: + """Get all agents for a project, aggregated from all active sessions. + + Each agent includes a reference to its session for simulation/editing. + """ + # Get ALL active sessions for this project + result = await db.execute( + select(DesignSession) + .where(DesignSession.project_id == project_id) + .where(DesignSession.status == DesignSessionStatus.ACTIVE.value) + .order_by(DesignSession.created_at.asc()) # Oldest first for consistent ordering + ) + sessions = result.scalars().all() + + all_agents: list[ProjectAgentInfo] = [] + + for session in sessions: + blueprint_state = session.blueprint_state or {} + agents = blueprint_state.get("agents", []) + + for idx, agent in enumerate(agents): + all_agents.append(ProjectAgentInfo( + session_id=session.id, + agent_index=idx, + name=agent.get("name", f"Agent {len(all_agents) + 1}"), + persona=agent.get("persona"), + topics=agent.get("topics", []), + tone=agent.get("tone"), + system_prompt=agent.get("system_prompt"), + context_files=agent.get("context_files"), + )) + + return ProjectAgentsResponse( + project_id=project_id, + agents=all_agents, + session_count=len(sessions), + ) + + @router.delete("/{session_id}") async def delete_session( session_id: str, diff --git a/src/backend/clara/config.py b/src/backend/clara/config.py index 38d92fb..b035d70 100644 --- a/src/backend/clara/config.py +++ b/src/backend/clara/config.py @@ -27,6 +27,37 @@ class Settings(BaseSettings): # AI Models simulation_interviewer_model: str = "sonnet" simulation_user_model: str = "haiku" + web_search_model: str = "claude-sonnet-4-20250514" + + # Anthropic API (uses ANTHROPIC_API_KEY env var by default) + anthropic_api_key: str | None = None + + # File Upload Configuration + upload_dir: str = "./uploads" # Local storage path (use S3 in production) + max_file_size_mb: int = 25 # Maximum file size in MB + max_files_per_agent: int = 10 # Maximum files per agent + allowed_file_extensions: list[str] = [ + ".pdf", ".docx", ".doc", ".xlsx", ".xls", + ".txt", ".md", ".csv", + ".png", ".jpg", ".jpeg", ".gif", ".webp" + ] + # MIME type whitelist (validated against file content, not just extension) + allowed_mime_types: list[str] = [ + # Documents + "application/pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/msword", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-excel", + "text/plain", + "text/markdown", + "text/csv", + # Images + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + ] settings = Settings() diff --git a/src/backend/clara/db/models.py b/src/backend/clara/db/models.py index 349ffda..5202ec3 100644 --- a/src/backend/clara/db/models.py +++ b/src/backend/clara/db/models.py @@ -281,3 +281,69 @@ class InterviewSession(Base): project: Mapped["Project"] = relationship(back_populates="interview_sessions") agent: Mapped["Agent"] = relationship(back_populates="interview_sessions") interviewee: Mapped["Interviewee"] = relationship(back_populates="interview_sessions") + + +class ContextFileStatus(str, Enum): + """Status of a context file.""" + PENDING = "pending" # Upload started, validation pending + SCANNING = "scanning" # Malware scan in progress + PROCESSING = "processing" # Content extraction in progress + READY = "ready" # File ready for use + FAILED = "failed" # Validation or processing failed + INFECTED = "infected" # Malware detected + + +class AgentContextFile(Base): + """Context files uploaded for interview agents. + + Files are sandboxed per project/agent and used to provide additional + context to the interview agent during sessions. + """ + + __tablename__ = "agent_context_files" + __table_args__ = ( + Index("ix_acf_session_agent", "session_id", "agent_index"), + Index("ix_acf_project", "project_id"), + Index("ix_acf_status", "status"), + Index("ix_acf_checksum", "checksum"), + ) + + id: Mapped[str] = mapped_column(String(50), primary_key=True) + session_id: Mapped[str] = mapped_column( + ForeignKey("design_sessions.id", ondelete="CASCADE"), + nullable=False + ) + project_id: Mapped[str] = mapped_column(String(30), nullable=False) + agent_index: Mapped[int] = mapped_column(Integer, nullable=False) + + # File metadata + original_filename: Mapped[str] = mapped_column(String(255), nullable=False) + stored_filename: Mapped[str] = mapped_column(String(255), nullable=False) # Sanitized name with UUID + file_extension: Mapped[str] = mapped_column(String(20), nullable=False) + mime_type: Mapped[str] = mapped_column(String(100), nullable=False) + file_size: Mapped[int] = mapped_column(Integer, nullable=False) # bytes + + # Storage path (relative to upload_dir, sandboxed by project) + storage_path: Mapped[str] = mapped_column(String(500), nullable=False) + + # Content for agent context (extracted text, max 50KB) + extracted_text: Mapped[str | None] = mapped_column(Text) + extraction_status: Mapped[str | None] = mapped_column(String(50)) # success, partial, failed, unsupported + + # Security + checksum: Mapped[str] = mapped_column(String(64), nullable=False) # SHA-256 + status: Mapped[str] = mapped_column(String(20), default=ContextFileStatus.PENDING.value) + status_message: Mapped[str | None] = mapped_column(String(500)) # Error details if failed + + # Audit + uploaded_by: Mapped[str] = mapped_column(String(50), default="system") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + # Relationship + session: Mapped["DesignSession"] = relationship() diff --git a/src/backend/clara/main.py b/src/backend/clara/main.py index 8663077..531ddf6 100644 --- a/src/backend/clara/main.py +++ b/src/backend/clara/main.py @@ -8,6 +8,7 @@ from clara.agents.design_assistant import session_manager from clara.agents.simulation_agent import simulation_manager +from clara.api.context_files import router as context_files_router from clara.api.design_sessions import router as design_sessions_router from clara.api.projects import router as projects_router from clara.api.simulation_sessions import router as simulation_sessions_router @@ -70,6 +71,7 @@ async def log_requests(request: Request, call_next): app.include_router(projects_router, prefix="/api/v1") app.include_router(design_sessions_router, prefix="/api/v1") app.include_router(simulation_sessions_router, prefix="/api/v1") +app.include_router(context_files_router, prefix="/api/v1") @app.get("/health") diff --git a/src/backend/clara/services/file_service.py b/src/backend/clara/services/file_service.py new file mode 100644 index 0000000..f3f266a --- /dev/null +++ b/src/backend/clara/services/file_service.py @@ -0,0 +1,599 @@ +"""File upload service with security, storage, and content extraction. + +Provides sandboxed file storage per project/agent with: +- File type validation (extension + magic bytes) +- Size limits and filename sanitization +- Path traversal prevention +- Content extraction for agent context +""" + +import hashlib +import logging +import os +import re +import uuid +from dataclasses import dataclass +from pathlib import Path + +from clara.config import settings + +logger = logging.getLogger(__name__) + +# Magic bytes for file type detection (first N bytes) +# More reliable than extension-only validation +FILE_SIGNATURES = { + # PDF + b"%PDF": "application/pdf", + # Office Open XML (docx, xlsx, pptx) - ZIP-based + b"PK\x03\x04": "application/zip", # Will need further inspection + # Old Office formats + b"\xd0\xcf\x11\xe0": "application/msword", # DOC, XLS, PPT (OLE2) + # Images + b"\x89PNG\r\n\x1a\n": "image/png", + b"\xff\xd8\xff": "image/jpeg", + b"GIF87a": "image/gif", + b"GIF89a": "image/gif", + b"RIFF": "image/webp", # Will verify WEBP specifically +} + +# Additional checks for ZIP-based formats +OOXML_CONTENT_TYPES = { + b"word/": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + b"xl/": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + b"ppt/": "application/vnd.openxmlformats-officedocument.presentationml.presentation", +} + + +@dataclass +class FileValidationResult: + """Result of file validation.""" + is_valid: bool + mime_type: str | None = None + error_message: str | None = None + checksum: str | None = None + + +@dataclass +class FileUploadResult: + """Result of a file upload operation.""" + success: bool + file_id: str | None = None + storage_path: str | None = None + stored_filename: str | None = None + mime_type: str | None = None + file_size: int = 0 + checksum: str | None = None + extracted_text: str | None = None + extraction_status: str | None = None + error_message: str | None = None + + +class FileSecurityService: + """Handles file validation and security checks.""" + + # Dangerous filename patterns + DANGEROUS_PATTERNS = [ + r"\.\.[\\/]", # Path traversal + r"^[\\/]", # Absolute paths + r"[<>:\"|?*]", # Invalid characters + r"[\x00-\x1f]", # Control characters + r"^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\.|$)", # Windows reserved names + ] + + # Maximum filename length + MAX_FILENAME_LENGTH = 200 + + @classmethod + def sanitize_filename(cls, filename: str) -> str: + """Sanitize a filename to prevent security issues. + + Args: + filename: The original filename + + Returns: + Sanitized filename safe for storage + """ + if not filename: + return "unnamed_file" + + # Normalize unicode + filename = filename.encode('utf-8', errors='ignore').decode('utf-8') + + # Get just the filename, not any path components + filename = os.path.basename(filename) + + # Remove or replace dangerous characters + for pattern in cls.DANGEROUS_PATTERNS: + filename = re.sub(pattern, "_", filename, flags=re.IGNORECASE) + + # Replace spaces and other problematic chars + filename = re.sub(r'[^\w.\-]', '_', filename) + + # Collapse multiple underscores + filename = re.sub(r'_+', '_', filename) + + # Truncate if too long (preserve extension) + if len(filename) > cls.MAX_FILENAME_LENGTH: + name, ext = os.path.splitext(filename) + max_name_len = cls.MAX_FILENAME_LENGTH - len(ext) - 1 + filename = name[:max_name_len] + ext + + # Ensure not empty + if not filename or filename == ".": + filename = "unnamed_file" + + return filename + + @classmethod + def validate_file( + cls, + file_content: bytes, + filename: str, + max_size_bytes: int | None = None + ) -> FileValidationResult: + """Validate a file for security and compliance. + + Args: + file_content: The raw file bytes + filename: The original filename + max_size_bytes: Maximum allowed size (uses config default if None) + + Returns: + FileValidationResult with validation status and details + """ + max_size = max_size_bytes or (settings.max_file_size_mb * 1024 * 1024) + + # Check file size + if len(file_content) > max_size: + return FileValidationResult( + is_valid=False, + error_message=f"File exceeds maximum size of {settings.max_file_size_mb}MB" + ) + + if len(file_content) == 0: + return FileValidationResult( + is_valid=False, + error_message="File is empty" + ) + + # Check extension + ext = os.path.splitext(filename)[1].lower() + if ext not in settings.allowed_file_extensions: + allowed = ', '.join(settings.allowed_file_extensions) + return FileValidationResult( + is_valid=False, + error_message=f"File type '{ext}' is not allowed. Allowed types: {allowed}" + ) + + # Detect MIME type from file content (magic bytes) + detected_mime = cls._detect_mime_type(file_content) + if not detected_mime: + # For text files, allow if extension is text-based + if ext in ['.txt', '.md', '.csv']: + # Verify it's actually text (no binary content) + try: + file_content.decode('utf-8') + detected_mime = "text/plain" if ext == '.txt' else ( + "text/markdown" if ext == '.md' else "text/csv" + ) + except UnicodeDecodeError: + return FileValidationResult( + is_valid=False, + error_message="File appears to be binary but has text extension" + ) + else: + return FileValidationResult( + is_valid=False, + error_message="Could not verify file type from content" + ) + + # Verify detected MIME type is allowed + if detected_mime not in settings.allowed_mime_types: + return FileValidationResult( + is_valid=False, + error_message=f"Detected file type '{detected_mime}' is not allowed" + ) + + # Calculate checksum + checksum = hashlib.sha256(file_content).hexdigest() + + return FileValidationResult( + is_valid=True, + mime_type=detected_mime, + checksum=checksum + ) + + @classmethod + def _detect_mime_type(cls, content: bytes) -> str | None: + """Detect MIME type from file content using magic bytes. + + Args: + content: File content bytes + + Returns: + Detected MIME type or None if unknown + """ + if len(content) < 8: + return None + + # Check magic bytes + for signature, mime_type in FILE_SIGNATURES.items(): + if content.startswith(signature): + # Special handling for ZIP-based formats (OOXML) + if mime_type == "application/zip": + return cls._detect_ooxml_type(content) + # Special handling for WEBP (RIFF container) + if signature == b"RIFF" and len(content) >= 12: + if content[8:12] == b"WEBP": + return "image/webp" + continue + return mime_type + + return None + + @classmethod + def _detect_ooxml_type(cls, content: bytes) -> str | None: + """Detect specific OOXML format from ZIP content. + + Args: + content: ZIP file content + + Returns: + Specific OOXML MIME type or generic ZIP + """ + # Look for directory markers in the ZIP content + for marker, mime_type in OOXML_CONTENT_TYPES.items(): + if marker in content[:2000]: # Check first 2KB + return mime_type + + # Generic ZIP (not an Office document) + return None + + +class FileStorageService: + """Handles sandboxed file storage.""" + + def __init__(self, base_path: str | None = None): + """Initialize storage service. + + Args: + base_path: Base directory for file storage (uses config default if None) + """ + self.base_path = Path(base_path or settings.upload_dir) + + def get_project_path(self, project_id: str, agent_index: int) -> Path: + """Get the sandboxed storage path for a project/agent. + + Args: + project_id: The project ID + agent_index: The agent index within the project + + Returns: + Path to the project/agent storage directory + """ + # Sanitize project_id to prevent path traversal + safe_project_id = re.sub(r'[^\w\-]', '_', project_id) + return self.base_path / safe_project_id / f"agent_{agent_index}" + + def ensure_directory(self, path: Path) -> None: + """Ensure a directory exists with proper permissions. + + Args: + path: Directory path to create + """ + path.mkdir(parents=True, exist_ok=True) + # Set restrictive permissions (owner only) + os.chmod(path, 0o700) + + def store_file( + self, + file_content: bytes, + project_id: str, + agent_index: int, + original_filename: str + ) -> tuple[str, str]: + """Store a file in the sandboxed location. + + Args: + file_content: The file content to store + project_id: Project ID for sandboxing + agent_index: Agent index for sandboxing + original_filename: Original filename (will be sanitized) + + Returns: + Tuple of (stored_filename, relative_storage_path) + """ + # Get sanitized filename with UUID prefix for uniqueness + safe_filename = FileSecurityService.sanitize_filename(original_filename) + unique_filename = f"{uuid.uuid4().hex[:8]}_{safe_filename}" + + # Get storage path + storage_dir = self.get_project_path(project_id, agent_index) + self.ensure_directory(storage_dir) + + # Write file + file_path = storage_dir / unique_filename + with open(file_path, 'wb') as f: + f.write(file_content) + + # Set restrictive permissions + os.chmod(file_path, 0o600) + + # Return relative path for database storage + relative_path = str(file_path.relative_to(self.base_path)) + return unique_filename, relative_path + + def read_file(self, storage_path: str) -> bytes | None: + """Read a file from storage. + + Args: + storage_path: Relative path to the file + + Returns: + File content or None if not found + """ + file_path = self.base_path / storage_path + + # Security check: ensure path is within base_path + try: + file_path.resolve().relative_to(self.base_path.resolve()) + except ValueError: + logger.warning(f"Path traversal attempt detected: {storage_path}") + return None + + if not file_path.exists(): + return None + + with open(file_path, 'rb') as f: + return f.read() + + def delete_file(self, storage_path: str) -> bool: + """Delete a file from storage. + + Args: + storage_path: Relative path to the file + + Returns: + True if deleted, False otherwise + """ + file_path = self.base_path / storage_path + + # Security check + try: + file_path.resolve().relative_to(self.base_path.resolve()) + except ValueError: + logger.warning(f"Path traversal attempt in delete: {storage_path}") + return False + + if file_path.exists(): + file_path.unlink() + return True + return False + + +class ContentExtractionService: + """Extracts text content from files for agent context.""" + + # Maximum extracted text length (50KB) + MAX_EXTRACTED_LENGTH = 50000 + + @classmethod + def extract_text(cls, content: bytes, mime_type: str) -> tuple[str | None, str]: + """Extract text content from a file. + + Args: + content: File content bytes + mime_type: The file's MIME type + + Returns: + Tuple of (extracted_text, extraction_status) + Status is one of: success, partial, failed, unsupported + """ + try: + if mime_type in ["text/plain", "text/markdown", "text/csv"]: + return cls._extract_text_file(content) + elif mime_type == "application/pdf": + return cls._extract_pdf(content) + elif mime_type in [ + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/msword" + ]: + return cls._extract_docx(content) + elif mime_type in [ + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-excel" + ]: + return cls._extract_xlsx(content) + elif mime_type.startswith("image/"): + # Images don't have extractable text (would need OCR) + return None, "unsupported" + else: + return None, "unsupported" + except Exception as e: + logger.exception(f"Error extracting content: {e}") + return None, "failed" + + @classmethod + def _extract_text_file(cls, content: bytes) -> tuple[str, str]: + """Extract from plain text file.""" + try: + text = content.decode('utf-8') + except UnicodeDecodeError: + try: + text = content.decode('latin-1') + except Exception: + return None, "failed" + + if len(text) > cls.MAX_EXTRACTED_LENGTH: + return text[:cls.MAX_EXTRACTED_LENGTH], "partial" + return text, "success" + + @classmethod + def _extract_pdf(cls, content: bytes) -> tuple[str | None, str]: + """Extract text from PDF file.""" + try: + import pypdf + except ImportError: + logger.warning("pypdf not installed, cannot extract PDF content") + return None, "unsupported" + + try: + import io + reader = pypdf.PdfReader(io.BytesIO(content)) + text_parts = [] + total_length = 0 + + for page in reader.pages: + page_text = page.extract_text() or "" + if total_length + len(page_text) > cls.MAX_EXTRACTED_LENGTH: + # Truncate at limit + remaining = cls.MAX_EXTRACTED_LENGTH - total_length + text_parts.append(page_text[:remaining]) + return "\n\n".join(text_parts), "partial" + + text_parts.append(page_text) + total_length += len(page_text) + + full_text = "\n\n".join(text_parts) + if full_text.strip(): + return full_text, "success" + return None, "failed" + + except Exception as e: + logger.warning(f"Failed to extract PDF: {e}") + return None, "failed" + + @classmethod + def _extract_docx(cls, content: bytes) -> tuple[str | None, str]: + """Extract text from DOCX file.""" + try: + from docx import Document + except ImportError: + logger.warning("python-docx not installed, cannot extract DOCX content") + return None, "unsupported" + + try: + import io + doc = Document(io.BytesIO(content)) + text_parts = [] + total_length = 0 + + for para in doc.paragraphs: + para_text = para.text + if total_length + len(para_text) > cls.MAX_EXTRACTED_LENGTH: + remaining = cls.MAX_EXTRACTED_LENGTH - total_length + text_parts.append(para_text[:remaining]) + return "\n\n".join(text_parts), "partial" + + text_parts.append(para_text) + total_length += len(para_text) + + full_text = "\n\n".join(text_parts) + if full_text.strip(): + return full_text, "success" + return None, "failed" + + except Exception as e: + logger.warning(f"Failed to extract DOCX: {e}") + return None, "failed" + + @classmethod + def _extract_xlsx(cls, content: bytes) -> tuple[str | None, str]: + """Extract text from XLSX file.""" + try: + import openpyxl + except ImportError: + logger.warning("openpyxl not installed, cannot extract XLSX content") + return None, "unsupported" + + try: + import io + wb = openpyxl.load_workbook(io.BytesIO(content), read_only=True, data_only=True) + text_parts = [] + total_length = 0 + + for sheet_name in wb.sheetnames: + sheet = wb[sheet_name] + text_parts.append(f"## Sheet: {sheet_name}") + + for row in sheet.iter_rows(values_only=True): + row_text = "\t".join(str(cell) if cell is not None else "" for cell in row) + if total_length + len(row_text) > cls.MAX_EXTRACTED_LENGTH: + return "\n".join(text_parts), "partial" + + text_parts.append(row_text) + total_length += len(row_text) + 1 + + full_text = "\n".join(text_parts) + if full_text.strip(): + return full_text, "success" + return None, "failed" + + except Exception as e: + logger.warning(f"Failed to extract XLSX: {e}") + return None, "failed" + + +class FileUploadService: + """High-level service for file uploads with full pipeline.""" + + def __init__(self): + self.storage = FileStorageService() + + async def upload_file( + self, + file_content: bytes, + filename: str, + project_id: str, + agent_index: int + ) -> FileUploadResult: + """Upload and process a file. + + Args: + file_content: The file content + filename: Original filename + project_id: Project ID for sandboxing + agent_index: Agent index for sandboxing + + Returns: + FileUploadResult with upload status and details + """ + # Step 1: Validate file + validation = FileSecurityService.validate_file(file_content, filename) + if not validation.is_valid: + return FileUploadResult( + success=False, + error_message=validation.error_message + ) + + # Step 2: Store file + try: + stored_filename, storage_path = self.storage.store_file( + file_content, project_id, agent_index, filename + ) + except Exception as e: + logger.exception("Failed to store file") + return FileUploadResult( + success=False, + error_message=f"Failed to store file: {str(e)}" + ) + + # Step 3: Extract content + extracted_text, extraction_status = ContentExtractionService.extract_text( + file_content, validation.mime_type + ) + + # Step 4: Generate file ID + file_id = f"file_{uuid.uuid4().hex[:16]}" + + return FileUploadResult( + success=True, + file_id=file_id, + storage_path=storage_path, + stored_filename=stored_filename, + mime_type=validation.mime_type, + file_size=len(file_content), + checksum=validation.checksum, + extracted_text=extracted_text, + extraction_status=extraction_status + ) diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index d79433f..26cb5cd 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -32,6 +32,12 @@ dev = [ "aiosqlite>=0.20.0", "greenlet>=3.0.0", ] +# Optional dependencies for content extraction from uploaded files +extraction = [ + "pypdf>=5.0.0", + "python-docx>=1.1.0", + "openpyxl>=3.1.0", +] [build-system] requires = ["hatchling"] diff --git a/src/backend/uv.lock b/src/backend/uv.lock index b45b36c..eae18af 100644 --- a/src/backend/uv.lock +++ b/src/backend/uv.lock @@ -288,6 +288,11 @@ dev = [ { name = "pytest-cov" }, { name = "ruff" }, ] +extraction = [ + { name = "openpyxl" }, + { name = "pypdf" }, + { name = "python-docx" }, +] [package.dev-dependencies] dev = [ @@ -306,12 +311,15 @@ requires-dist = [ { name = "greenlet", marker = "extra == 'dev'", specifier = ">=3.0.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.13.0" }, + { name = "openpyxl", marker = "extra == 'extraction'", specifier = ">=3.1.0" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "pydantic", specifier = ">=2.9.0" }, { name = "pydantic-settings", specifier = ">=2.6.0" }, + { name = "pypdf", marker = "extra == 'extraction'", specifier = ">=5.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.0.0" }, + { name = "python-docx", marker = "extra == 'extraction'", specifier = ">=1.1.0" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, { name = "python-multipart", specifier = ">=0.0.12" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7.0" }, @@ -319,7 +327,7 @@ requires-dist = [ { name = "ulid-py", specifier = ">=1.1.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.32.0" }, ] -provides-extras = ["dev"] +provides-extras = ["dev", "extraction"] [package.metadata.requires-dev] dev = [{ name = "httpx", specifier = ">=0.28.1" }] @@ -521,6 +529,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607 }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059 }, +] + [[package]] name = "fastapi" version = "0.125.0" @@ -815,6 +832,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/e9/a0aa60f5322814dd084a89614e9e31139702e342f8459ad8af1984a18168/librt-0.7.4-cp314-cp314t-win_arm64.whl", hash = "sha256:76b2ba71265c0102d11458879b4d53ccd0b32b0164d14deb8d2b598a018e502f", size = 39724 }, ] +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887 }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818 }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807 }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179 }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044 }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685 }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127 }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958 }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541 }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426 }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917 }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795 }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759 }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666 }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989 }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456 }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793 }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836 }, + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494 }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146 }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932 }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060 }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000 }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496 }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779 }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072 }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675 }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171 }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175 }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688 }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655 }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695 }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841 }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700 }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347 }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248 }, + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801 }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403 }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974 }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953 }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054 }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421 }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684 }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463 }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437 }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890 }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185 }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895 }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246 }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797 }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404 }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072 }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617 }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930 }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380 }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632 }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171 }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109 }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061 }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233 }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739 }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119 }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665 }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997 }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957 }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372 }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653 }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795 }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023 }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420 }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837 }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205 }, +] + [[package]] name = "mako" version = "1.3.10" @@ -957,6 +1054,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910 }, +] + [[package]] name = "packaging" version = "25.0" @@ -1139,6 +1248,15 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pypdf" +version = "6.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/9b/db1056a54eda8cd44f9e5128e87e1142cb328295dad92bbec0d39f251641/pypdf-6.5.0.tar.gz", hash = "sha256:9e78950906380ae4f2ce1d9039e9008098ba6366a4d9c7423c4bdbd6e6683404", size = 5277655 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/db/f2e7703791a1f32532618b82789ddddb7173b9e22d97e34cc11950d8e330/pypdf-6.5.0-py3-none-any.whl", hash = "sha256:9cef8002aaedeecf648dfd9ff1ce38f20ae8d88e2534fced6630038906440b25", size = 329560 }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -1182,6 +1300,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424 }, ] +[[package]] +name = "python-docx" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987 }, +] + [[package]] name = "python-dotenv" version = "1.2.1" diff --git a/src/frontend/src/api/context-files.ts b/src/frontend/src/api/context-files.ts new file mode 100644 index 0000000..9f3c7da --- /dev/null +++ b/src/frontend/src/api/context-files.ts @@ -0,0 +1,111 @@ +/** + * Context Files API client for agent file uploads. + */ + +const API_BASE = '/api/v1/context-files' + +export interface ContextFile { + id: string + name: string + type: string + size: number + status: string + extraction_status: string | null + uploaded_at: string +} + +export interface UploadResponse { + success: boolean + file: ContextFile | null + error: string | null +} + +export interface ContextFileListResponse { + files: ContextFile[] + total: number +} + +export const contextFilesApi = { + /** + * Upload a file for an agent's context. + */ + async uploadFile( + sessionId: string, + agentIndex: number, + file: File + ): Promise { + const formData = new FormData() + formData.append('file', file) + + const response = await fetch( + `${API_BASE}/sessions/${sessionId}/agents/${agentIndex}/upload`, + { + method: 'POST', + body: formData, + } + ) + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Upload failed' })) + return { + success: false, + file: null, + error: error.detail || 'Upload failed', + } + } + + return response.json() + }, + + /** + * List all files for an agent. + */ + async listFiles(sessionId: string, agentIndex: number): Promise { + const response = await fetch( + `${API_BASE}/sessions/${sessionId}/agents/${agentIndex}` + ) + + if (!response.ok) { + throw new Error('Failed to list files') + } + + return response.json() + }, + + /** + * Delete a file. + */ + async deleteFile( + sessionId: string, + agentIndex: number, + fileId: string + ): Promise { + const response = await fetch( + `${API_BASE}/sessions/${sessionId}/agents/${agentIndex}/files/${fileId}`, + { method: 'DELETE' } + ) + + if (!response.ok) { + throw new Error('Failed to delete file') + } + }, + + /** + * Get extracted content of a file. + */ + async getContent( + sessionId: string, + agentIndex: number, + fileId: string + ): Promise<{ content: string | null; extraction_status: string }> { + const response = await fetch( + `${API_BASE}/sessions/${sessionId}/agents/${agentIndex}/files/${fileId}/content` + ) + + if (!response.ok) { + throw new Error('Failed to get file content') + } + + return response.json() + }, +} diff --git a/src/frontend/src/api/design-sessions.ts b/src/frontend/src/api/design-sessions.ts index e5da842..8ecbceb 100644 --- a/src/frontend/src/api/design-sessions.ts +++ b/src/frontend/src/api/design-sessions.ts @@ -9,6 +9,7 @@ import type { SessionInfo, SessionStateResponse, AGUIEvent, + ProjectAgentsResponse, } from '../types/design-session'; const BASE_URL = '/api/v1/design-sessions'; @@ -57,6 +58,13 @@ export async function getSessionByProject(projectId: string): Promise { + return apiRequest(`${BASE_URL}/project/${projectId}/agents`); +} + /** * Parse SSE event from raw text. */ @@ -150,6 +158,7 @@ export const designSessionsApi = { get: getSession, getFullSession, getByProject: getSessionByProject, + getProjectAgents, delete: deleteSession, streamMessage, }; diff --git a/src/frontend/src/hooks/useDesignSession.ts b/src/frontend/src/hooks/useDesignSession.ts index 61024f1..a49cc3c 100644 --- a/src/frontend/src/hooks/useDesignSession.ts +++ b/src/frontend/src/hooks/useDesignSession.ts @@ -22,6 +22,7 @@ import type { interface UseDesignSessionOptions { projectId: string; + addAgent?: boolean; } interface UseDesignSessionReturn { @@ -72,6 +73,7 @@ const initialSessionState: DesignSessionState = { export function useDesignSession({ projectId, + addAgent = false, }: UseDesignSessionOptions): UseDesignSessionReturn { const [sessionId, setSessionId] = useState(null); const [isConnected, setIsConnected] = useState(false); @@ -170,12 +172,16 @@ export function useDesignSession({ setError(null); try { - const response = await designSessionsApi.create({ project_id: projectId }); + const response = await designSessionsApi.create({ + project_id: projectId, + add_agent: addAgent, + }); setSessionId(response.session_id); setIsConnected(true); - if (response.is_new) { - // New session - start fresh + if (response.is_new || addAgent) { + // New session or add agent mode - start fresh conversation + // For addAgent, backend creates new session with copied blueprint state setSessionState(initialSessionState); setMessages([]); } else { @@ -189,7 +195,7 @@ export function useDesignSession({ } finally { setIsLoading(false); } - }, [projectId, isConnected, restoreSessionState]); + }, [projectId, addAgent, isConnected, restoreSessionState]); const disconnect = useCallback(async () => { if (!sessionId) return; diff --git a/src/frontend/src/pages/DesignAssistantPage.tsx b/src/frontend/src/pages/DesignAssistantPage.tsx index a719a7e..41d955d 100644 --- a/src/frontend/src/pages/DesignAssistantPage.tsx +++ b/src/frontend/src/pages/DesignAssistantPage.tsx @@ -3,7 +3,7 @@ */ import { useEffect, useRef, useState } from 'react'; -import { useParams, useNavigate, Link } from 'react-router-dom'; +import { useParams, useNavigate, Link, useSearchParams } from 'react-router-dom'; import { useDesignSession } from '../hooks/useDesignSession'; import { ChatMessage, @@ -14,6 +14,8 @@ import { export function DesignAssistantPage() { const { projectId } = useParams<{ projectId: string }>(); + const [searchParams] = useSearchParams(); + const addAgent = searchParams.get('addAgent') === 'true'; const navigate = useNavigate(); const messagesEndRef = useRef(null); const [isDebugOpen, setIsDebugOpen] = useState(false); @@ -32,7 +34,7 @@ export function DesignAssistantPage() { sendMessage, disconnect, clearPendingUIComponent, - } = useDesignSession({ projectId: projectId || 'default' }); + } = useDesignSession({ projectId: projectId || 'default', addAgent }); // Check if we have agents in the blueprint (meaning simulation is available) const hasBlueprint = (sessionState?.preview?.agent_count ?? 0) > 0; diff --git a/src/frontend/src/pages/ProjectDetailPage.tsx b/src/frontend/src/pages/ProjectDetailPage.tsx index 775b536..3b8b215 100644 --- a/src/frontend/src/pages/ProjectDetailPage.tsx +++ b/src/frontend/src/pages/ProjectDetailPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useParams, useNavigate, Link } from 'react-router-dom' import { format } from 'date-fns' import clsx from 'clsx' @@ -9,8 +9,12 @@ import { useDeleteProject, } from '../hooks/useProjects' import { designSessionsApi } from '../api/design-sessions' +import { contextFilesApi } from '../api/context-files' import type { ProjectStatus, ProjectUpdate } from '../types/project' -import type { SessionStateResponse } from '../types/design-session' +import type { ProjectAgentInfo, ProjectAgentsResponse } from '../types/design-session' + +// Accepted file types for context upload +const ACCEPTED_FILE_TYPES = '.xlsx,.xls,.doc,.docx,.pdf,.png,.jpg,.jpeg,.gif,.webp' const statusStyles: Record = { draft: 'bg-gray-100 text-gray-700', @@ -29,16 +33,79 @@ export default function ProjectDetailPage() { const [editDescription, setEditDescription] = useState('') const [editStatus, setEditStatus] = useState('draft') const [editTags, setEditTags] = useState('') - const [designSession, setDesignSession] = useState(null) + const [projectAgents, setProjectAgents] = useState(null) + const [expandedPrompts, setExpandedPrompts] = useState>(new Set()) + const [uploadingAgent, setUploadingAgent] = useState(null) // session_id:agent_index + const [uploadError, setUploadError] = useState(null) + const fileInputRefs = useRef<{ [key: string]: HTMLInputElement | null }>({}) + + const togglePromptExpanded = (index: number) => { + setExpandedPrompts((prev) => { + const next = new Set(prev) + if (next.has(index)) { + next.delete(index) + } else { + next.add(index) + } + return next + }) + } + + const handleFileUpload = async (agent: ProjectAgentInfo, files: FileList | null) => { + if (!files || files.length === 0) return - // Fetch design session to check if we have a blueprint + const agentKey = `${agent.session_id}:${agent.agent_index}` + setUploadingAgent(agentKey) + setUploadError(null) + + try { + for (const file of Array.from(files)) { + const result = await contextFilesApi.uploadFile( + agent.session_id, + agent.agent_index, + file + ) + + if (!result.success) { + setUploadError(result.error || 'Upload failed') + break + } + } + + // Refresh the agents to get updated file list + const updated = await designSessionsApi.getProjectAgents(projectId!) + setProjectAgents(updated) + } catch (err) { + setUploadError(err instanceof Error ? err.message : 'Upload failed') + } finally { + setUploadingAgent(null) + // Reset file input + const agentKey = `${agent.session_id}:${agent.agent_index}` + if (fileInputRefs.current[agentKey]) { + fileInputRefs.current[agentKey]!.value = '' + } + } + } + + const handleDeleteFile = async (agent: ProjectAgentInfo, fileId: string) => { + try { + await contextFilesApi.deleteFile(agent.session_id, agent.agent_index, fileId) + // Refresh agents + const updated = await designSessionsApi.getProjectAgents(projectId!) + setProjectAgents(updated) + } catch (err) { + setUploadError(err instanceof Error ? err.message : 'Delete failed') + } + } + + // Fetch project agents (aggregated from all sessions) useEffect(() => { if (!projectId) return - designSessionsApi.getByProject(projectId).then(setDesignSession).catch(() => {}) + designSessionsApi.getProjectAgents(projectId).then(setProjectAgents).catch(() => {}) }, [projectId]) - // Check if we have agents in the blueprint (meaning simulation is available) - const hasBlueprint = (designSession?.blueprint_state?.agents?.length ?? 0) > 0 + // Check if we have agents (meaning simulation is available) + const hasBlueprint = (projectAgents?.agents?.length ?? 0) > 0 const updateMutation = useUpdateProject() const archiveMutation = useArchiveProject() @@ -202,10 +269,10 @@ export default function ProjectDetailPage() {
- Design Blueprint + {hasBlueprint ? 'Add Interview Agent' : 'Create Interview Agent'} + {expandedPrompts.has(index) && ( +
+
+                              {agent.system_prompt}
+                            
+
+ )} +
+ )} + + {/* Context Files Section */} +
+ + Context Files + + {uploadError && uploadingAgent === null && ( +
+ {uploadError} +
+ )} + {agent.context_files && agent.context_files.length > 0 ? ( +
+ {agent.context_files.map((file) => ( + + + + + {file.name} + + + ))} +
+ ) : ( +

No context files uploaded

+ )} + (fileInputRefs.current[agentKey] = el)} + className="hidden" + accept={ACCEPTED_FILE_TYPES} + multiple + onChange={(e) => handleFileUpload(agent, e.target.files)} + /> + +
Simulate Auto-Simulate @@ -376,7 +571,7 @@ export default function ProjectDetailPage() {
- ))} + )})} ) : (
diff --git a/src/frontend/src/types/design-session.ts b/src/frontend/src/types/design-session.ts index 142bf95..768bd91 100644 --- a/src/frontend/src/types/design-session.ts +++ b/src/frontend/src/types/design-session.ts @@ -106,6 +106,7 @@ export interface DesignSessionState { // API Types export interface CreateSessionRequest { project_id: string; + add_agent?: boolean; } export interface CreateSessionResponse { @@ -145,6 +146,14 @@ export interface SessionStateResponse { persona?: string; topics: string[]; tone?: string; + system_prompt?: string; + context_files?: Array<{ + id: string; + name: string; + type: string; + size: number; + uploaded_at: string; + }>; }>; }; goal_summary: Record | null; @@ -163,6 +172,30 @@ export interface SendMessageRequest { message: string; } +// Project-level agent info (from aggregation endpoint) +export interface ProjectAgentInfo { + session_id: string; + agent_index: number; + name: string; + persona: string | null; + topics: string[]; + tone: string | null; + system_prompt: string | null; + context_files: Array<{ + id: string; + name: string; + type: string; + size: number; + uploaded_at: string; + }> | null; +} + +export interface ProjectAgentsResponse { + project_id: string; + agents: ProjectAgentInfo[]; + session_count: number; +} + // Chat Message Types export type MessageRole = 'user' | 'assistant';