From 96e5c7ffeed6d52e6fced063c4f3da1ef7b6bf8a Mon Sep 17 00:00:00 2001 From: Hassan Alkhafaji Date: Mon, 13 Apr 2026 15:48:43 -0400 Subject: [PATCH 1/4] Created an animation for chat messages that are taking a while to be generated from the model --- LexAI_iOS/LexAI_iOS/Views/ChatView.swift | 6 +++ .../Views/Components/ThinkingBubbleView.swift | 38 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 LexAI_iOS/LexAI_iOS/Views/Components/ThinkingBubbleView.swift diff --git a/LexAI_iOS/LexAI_iOS/Views/ChatView.swift b/LexAI_iOS/LexAI_iOS/Views/ChatView.swift index 6218f83..2bbed9b 100644 --- a/LexAI_iOS/LexAI_iOS/Views/ChatView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/ChatView.swift @@ -159,6 +159,12 @@ struct ChatView: View { ForEach(messages) { message in MessageBubbleView(message: message) } + + if isAwaitingReply { + ThinkingBubbleView() + .transition(.opacity.combined(with: .scale(scale: 0.8))) + } + Color.clear .frame(height: 8) .id(bottomAnchorId) diff --git a/LexAI_iOS/LexAI_iOS/Views/Components/ThinkingBubbleView.swift b/LexAI_iOS/LexAI_iOS/Views/Components/ThinkingBubbleView.swift new file mode 100644 index 0000000..112666e --- /dev/null +++ b/LexAI_iOS/LexAI_iOS/Views/Components/ThinkingBubbleView.swift @@ -0,0 +1,38 @@ +// +// ThinkingBubbleView.swift +// LexAI_iOS +// +// Created by Hassan Alkhafaji on 4/13/26. +// +import SwiftUI + +struct ThinkingBubbleView: View { + @State private var animating = false + + var body: some View { + HStack(alignment: .top) { + HStack(spacing: 6) { + ForEach(0..<3) { index in + Circle() + .fill(Color.gray) + .frame(width: 8, height: 8) + .scaleEffect(animating ? 1.0 : 0.5) + .opacity(animating ? 1.0 : 0.4) + .animation( + .easeInOut(duration: 0.6) + .repeatForever(autoreverses: true) + .delay(Double(index) * 0.2), + value: animating + ) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + + Spacer(minLength: 48) + } + .onAppear { animating = true } + } +} From 4a522d8f9860f52db6a0e4c8327b34abba18842e Mon Sep 17 00:00:00 2001 From: Sara Al-hachami Date: Mon, 13 Apr 2026 16:15:00 -0400 Subject: [PATCH 2/4] More sidebar + view updates --- .../LexAI_iOS/Managers/FirebaseManager.swift | 250 ++++++++----- LexAI_iOS/LexAI_iOS/Views/ChatView.swift | 339 ++---------------- LexAI_iOS/LexAI_iOS/Views/ContentView.swift | 7 +- LexAI_iOS/LexAI_iOS/Views/HomeView.swift | 50 ++- LexAI_iOS/LexAI_iOS/Views/SideBarView.swift | 78 ++-- 5 files changed, 262 insertions(+), 462 deletions(-) diff --git a/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift b/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift index 015f11a..b6a4cd4 100644 --- a/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift +++ b/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift @@ -1,25 +1,26 @@ // -// AuthManager.swift +// FirebaseManager.swift // LexAI_iOS // // Created by Hassan Alkhafaji on 2/16/26. +// Extended with session/message persistence — Sprint 4 // import Foundation import Combine import FirebaseAuth -import FirebaseFirestore // new import +import FirebaseFirestore -//changed class name + filename to FirebaseManager - Mirshod class FirebaseManager: ObservableObject { - + @Published var user: User? @Published var isAuthenticated = false @Published var errorMessage: String? @Published var isLoading = false - + private var authStateListener: AuthStateDidChangeListenerHandle? - + private let db = Firestore.firestore() + init() { authStateListener = Auth.auth().addStateDidChangeListener { [weak self] _, user in Task { @MainActor in @@ -27,8 +28,6 @@ class FirebaseManager: ObservableObject { self?.isAuthenticated = user != nil } } - - // Ensure callable Functions can be tested without UI sign-in. if Auth.auth().currentUser == nil { Task { @MainActor in do { @@ -39,19 +38,132 @@ class FirebaseManager: ObservableObject { } } } - + deinit { if let listener = authStateListener { Auth.auth().removeStateDidChangeListener(listener) } } - + // MARK: - Firestore path helpers - // MARK: -chat storage - mirshod 3/13 - func saveChat(prompt: ChatPrompt, completion: @escaping (Bool) -> Void) { - let db = Firestore.firestore() + private var uid: String? { Auth.auth().currentUser?.uid } + + private func sessionsRef() -> CollectionReference? { + guard let uid else { return nil } + return db.collection("users").document(uid).collection("sessions") + } + + private func messagesRef(sessionId: String) -> CollectionReference? { + return sessionsRef()?.document(sessionId).collection("messages") + } + + // MARK: - Session: Create + + func createSession(_ session: ChatSession) { + guard let ref = sessionsRef() else { return } + var data: [String: Any] = [ + "title": session.title, + "preview": session.preview, + "createdAt": Timestamp(date: session.createdAt) + ] + if let tag = session.tag { data["tag"] = tag.rawValue } + ref.document(session.id.uuidString).setData(data) + } + + // MARK: - Session: Load all (for sidebar) + + func loadSessions(completion: @escaping ([ChatSession]) -> Void) { + guard let ref = sessionsRef() else { completion([]); return } + ref.order(by: "createdAt", descending: true).getDocuments { snapshot, error in + if let error { print("loadSessions error: \(error)"); completion([]); return } + + let sessions: [ChatSession] = snapshot?.documents.compactMap { doc in + let d = doc.data() + return ChatSession( + id: UUID(uuidString: doc.documentID) ?? UUID(), + title: d["title"] as? String ?? "Conversation", + preview: d["preview"] as? String ?? "", + tag: (d["tag"] as? String).flatMap { SessionTag(rawValue: $0) }, + createdAt: (d["createdAt"] as? Timestamp)?.dateValue() ?? Date() + ) + } ?? [] + + DispatchQueue.main.async { completion(sessions) } + } + } + + // MARK: - Session: Update title/preview/tag + + func updateSession(_ session: ChatSession) { + guard let ref = sessionsRef() else { return } + var data: [String: Any] = [ + "title": session.title, + "preview": session.preview + ] + if let tag = session.tag { data["tag"] = tag.rawValue } + ref.document(session.id.uuidString).updateData(data) + } + + // MARK: - Session: Rename + + func renameSession(_ session: ChatSession, to title: String) { + guard let ref = sessionsRef() else { return } + ref.document(session.id.uuidString).updateData(["title": title]) + } + + // MARK: - Session: Delete (+ all messages) + + func deleteSession(_ session: ChatSession) { + guard let ref = sessionsRef() else { return } + let sessionDoc = ref.document(session.id.uuidString) + sessionDoc.collection("messages").getDocuments { snapshot, _ in + let batch = self.db.batch() + snapshot?.documents.forEach { batch.deleteDocument($0.reference) } + batch.deleteDocument(sessionDoc) + batch.commit() + } + } + + // MARK: - Message: Save + + func saveMessage(_ message: ChatMessage, to session: ChatSession) { + guard let ref = messagesRef(sessionId: session.id.uuidString) else { return } + ref.document(message.id.uuidString).setData([ + "text": message.text, + "isFromUser": message.isFromUser, + "date": Timestamp(date: message.date) + ]) + } + + // MARK: - Message: Load all for a session + + func loadMessages(for session: ChatSession, completion: @escaping ([ChatMessage]) -> Void) { + guard let ref = messagesRef(sessionId: session.id.uuidString) else { completion([]); return } + + ref.order(by: "date", descending: false).getDocuments { snapshot, error in + if let error { print("loadMessages error: \(error)"); completion([]); return } + + let messages: [ChatMessage] = snapshot?.documents.compactMap { doc in + let d = doc.data() + guard let text = d["text"] as? String, + let isFromUser = d["isFromUser"] as? Bool else { return nil } + return ChatMessage( + id: UUID(uuidString: doc.documentID) ?? UUID(), + text: text, + isFromUser: isFromUser, + date: (d["date"] as? Timestamp)?.dateValue() ?? Date() + ) + } ?? [] + + DispatchQueue.main.async { completion(messages) } + } + } + + // MARK: - Legacy: single-prompt chat history (kept for backwards compatibility) + + func saveChat(prompt: ChatPrompt, completion: @escaping (Bool) -> Void) { let data: [String: Any] = [ "prompt": prompt.prompt, "documents": prompt.documents, @@ -60,71 +172,47 @@ class FirebaseManager: ObservableObject { "user": prompt.user, "timestamp": FieldValue.serverTimestamp() ] - db.collection("chatHistory").addDocument(data: data) { error in - if let error = error { - print("Error saving chat: \(error)") - completion(false) - } else{ - print("Chat successfully saved.") - completion(true) - } + completion(error == nil) } } - // MARK: - fetching chats (could be used in sidebar) - mirshod 3/24 func fetchChats(userId: String, completion: @escaping ([ChatPrompt]) -> Void) { - let db = Firestore.firestore() - db.collection("chatHistory") .whereField("user", isEqualTo: userId) - .getDocuments { snapshot, error in - var chats: [ChatPrompt] = [] - - if let documents = snapshot?.documents { - for doc in documents { - let data = doc.data() - - let chat = ChatPrompt( - prompt: data["prompt"] as? String ?? "", - documents: data["documents"] as? [String] ?? [], - location: data["location"] as? String ?? "", - language: "", - user: data["user"] as? String ?? "" - ) - chats.append(chat) - } - } + .getDocuments { snapshot, _ in + let chats: [ChatPrompt] = snapshot?.documents.compactMap { doc in + let d = doc.data() + return ChatPrompt( + prompt: d["prompt"] as? String ?? "", + documents: d["documents"] as? [String] ?? [], + location: d["location"] as? String ?? "", + language: d["language"] as? String ?? "", + user: d["user"] as? String ?? "" + ) + } ?? [] completion(chats) } } - // MARK: - deleting a chat - mirshod 3/24 func deleteChat(chatId: String, completion: @escaping (Bool) -> Void) { - let db = Firestore.firestore() - db.collection("chatHistory") - .document(chatId) - .delete { error in - completion(error == nil) - } + db.collection("chatHistory").document(chatId).delete { error in + completion(error == nil) + } } - // MARK - updating chat storage - mirshod 3/24 func updateChat(chatId: String, newPrompt: String, completion: @escaping (Bool) -> Void) { - let db = Firestore.firestore() - - db.collection("chatHistory") - .document(chatId) + db.collection("chatHistory").document(chatId) .updateData(["prompt": newPrompt]) { error in completion(error == nil) } } - - + + // MARK: - Auth + func signUp(email: String, password: String) async { isLoading = true errorMessage = nil - do { let result = try await Auth.auth().createUser(withEmail: email, password: password) self.user = result.user @@ -132,36 +220,23 @@ class FirebaseManager: ObservableObject { } catch { self.errorMessage = mapFirebaseError(error) } - isLoading = false } - -// Import XCTests -// Add the special tag over the Test function -// Create a function definition that will call the function your testing with mock data -// Test failures -// Test successes - - + @MainActor func signIn(email: String, password: String) async { isLoading = true errorMessage = nil - do { let result = try await Auth.auth().signIn(withEmail: email, password: password) self.user = result.user self.isAuthenticated = true - print("*****Successfully signed in") - } catch { self.errorMessage = mapFirebaseError(error) - print("SIGN IN ERROR: \(error)") // Add this to see the real error } - isLoading = false } - + @MainActor func signOut() { do { @@ -172,39 +247,28 @@ class FirebaseManager: ObservableObject { self.errorMessage = error.localizedDescription } } - - - + private func mapFirebaseError(_ error: Error) -> String { let nsError = error as NSError guard let errorCode = AuthErrorCode(rawValue: nsError.code) else { return error.localizedDescription } - switch errorCode { - case .emailAlreadyInUse: - return "This email is already in use." - case .invalidEmail: - return "Please enter a valid email address." - case .weakPassword: - return "Password must be at least 6 characters." - case .wrongPassword: - return "Incorrect password. Please try again." - case .userNotFound: - return "No account found with this email." - case .networkError: - return "Network error. Please check your connection." - case .tooManyRequests: - return "Too many attempts. Please try again later." - default: - return error.localizedDescription + case .emailAlreadyInUse: return "This email is already in use." + case .invalidEmail: return "Please enter a valid email address." + case .weakPassword: return "Password must be at least 6 characters." + case .wrongPassword: return "Incorrect password. Please try again." + case .userNotFound: return "No account found with this email." + case .networkError: return "Network error. Please check your connection." + case .tooManyRequests: return "Too many attempts. Please try again later." + default: return error.localizedDescription } } } +// MARK: - Models -// struct for storing chats -struct ChatPrompt{ +struct ChatPrompt { let prompt: String let documents: [String] let location: String diff --git a/LexAI_iOS/LexAI_iOS/Views/ChatView.swift b/LexAI_iOS/LexAI_iOS/Views/ChatView.swift index 6218f83..db64e86 100644 --- a/LexAI_iOS/LexAI_iOS/Views/ChatView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/ChatView.swift @@ -1,330 +1,57 @@ -// // ChatView.swift // LexAI_iOS -// import SwiftUI -import FirebaseFunctions -import UniformTypeIdentifiers -import PDFKit -import FirebaseAuth - -private let bottomAnchorId = "bottom" struct ChatView: View { - @State private var messages: [ChatMessage] = [] - @State private var inputText: String = "" - @State private var showDocumentMenu = false // bottom popup menu - @State private var showScanDocuments = false // camera scanner - @State private var showFilePicker = false // file importer - @State private var isAwaitingReply = false - - @Binding var selectedLanguage: String // language in conversation - private let functions = Functions.functions() - - var body: some View { - ZStack(alignment: .bottom) { - VStack(spacing: 0) { - Text("LexAI") - .font(.system(size: 46)) - .fontWeight(.semibold) - .foregroundStyle(Color("grape")) - .shadow(radius: 14, x: 0, y: 12) - - messageList - inputBar - } - .padding() - .background( - LinearGradient( - colors: [ - Color.white, - Color("grape").opacity(0.6), - Color("grape").opacity(0.9), - Color("grape"), - ], - startPoint: .top, - endPoint: .bottom - ) - .ignoresSafeArea() - ) - - // MARK: Dim tap-away layer - if showDocumentMenu { - Color.clear - .ignoresSafeArea() - .contentShape(Rectangle()) - .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { showDocumentMenu = false } } - } - - // MARK: Bottom popup menu (ChatGPT style) - if showDocumentMenu { - documentMenu - .transition(.move(edge: .bottom).combined(with: .opacity)) - .zIndex(10) - } - } - .animation(.easeInOut(duration: 0.22), value: showDocumentMenu) - // Camera scanner - .fullScreenCover(isPresented: $showScanDocuments) { - #if targetEnvironment(simulator) - VStack(spacing: 20) { - Image(systemName: "camera.viewfinder") - .font(.system(size: 48)) - .foregroundStyle(Color("grape")) - Text("Camera scanner is not\navailable in the simulator.") - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - Button("Dismiss") { showScanDocuments = false } - .buttonStyle(.borderedProminent) - } - .padding() - #else - ScanDocumentsView(isPresented: $showScanDocuments) { scannedText in - messages.append(ChatMessage(text: scannedText, isFromUser: true)) - } - #endif - } - // File picker - .fileImporter( - isPresented: $showFilePicker, - allowedContentTypes: [.plainText, .pdf], - allowsMultipleSelection: false - ) { result in - handleFileImport(result) - } - } + // Bindings owned by HomeView — keeps HomeView call site unchanged + @Binding var messages: [ChatMessage] + @Binding var selectedLanguage: String - // MARK: Document Menu + // Sidebar wiring — optional so the existing HomeView call compiles as-is. + // Pass vm + sessionID from HomeView when you're ready to enable title sync. + var vm: SidebarViewModel? = nil + var sessionID: UUID? = nil - private var documentMenu: some View { - VStack(alignment: .leading, spacing: 0) { - menuRow( - icon: "camera.viewfinder", - label: "Scan Document" - ) { - withAnimation { showDocumentMenu = false } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - showScanDocuments = true - } - } - - Divider().padding(.leading, 52) - - menuRow( - icon: "arrow.up.doc", - label: "Upload Document" - ) { - withAnimation { showDocumentMenu = false } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - showFilePicker = true - } - } - } - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(.systemGray6)) - .shadow(color: .black.opacity(0.18), radius: 20, x: 0, y: -4) - ) - .padding(.horizontal, 16) - .padding(.bottom, 90) // clears the input bar - } - - private func menuRow(icon: String, label: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack(spacing: 14) { - Image(systemName: icon) - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(.primary) - .frame(width: 28) - Text(label) - .font(.system(size: 16)) - .foregroundStyle(.primary) - Spacer() - } - .padding(.horizontal, 16) - .padding(.vertical, 15) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - - // MARK: Message List - - private var messageList: some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 12) { - ForEach(messages) { message in - MessageBubbleView(message: message) - } - Color.clear - .frame(height: 8) - .id(bottomAnchorId) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - .scrollDismissesKeyboard(.interactively) - .onChange(of: messages.count) { _, _ in - withAnimation(.easeOut(duration: 0.25)) { - proxy.scrollTo(bottomAnchorId, anchor: .bottom) - } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - // MARK: Input Bar - - private var inputBar: some View { - HStack(alignment: .bottom, spacing: 12) { - // + button — opens bottom menu - Button { - withAnimation(.easeInOut(duration: 0.22)) { - showDocumentMenu.toggle() - } - } label: { - Image(systemName: showDocumentMenu ? "xmark" : "plus") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 35, height: 35) - .background(Color.white.opacity(0.25), in: Circle()) - .animation(.easeInOut(duration: 0.18), value: showDocumentMenu) - } - .padding(.bottom, 4) - - TextField( - ChatPlaceholderText.placeholder(forSelectedLanguage: selectedLanguage), - text: $inputText, - axis: .vertical - ) - .textFieldStyle(.plain) - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(Color(.tertiarySystemGroupedBackground)) - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - .lineLimit(1...6) - - Button { sendMessage() } label: { - Image(systemName: isAwaitingReply ? "clock.arrow.circlepath" : "arrow.up.circle.fill") - .resizable() - .frame(width: 35, height: 35) - .foregroundStyle(inputText.isEmpty ? Color.white.opacity(0.6) : Color.white) - } - .disabled(inputText.isEmpty || isAwaitingReply) - .padding(.bottom, 4) - } - .padding(.horizontal, 4) - .padding(.top) - } - - // MARK: File Import Handler - - private func handleFileImport(_ result: Result<[URL], Error>) { - switch result { - case .success(let urls): - guard let url = urls.first else { return } - guard url.startAccessingSecurityScopedResource() else { return } - defer { url.stopAccessingSecurityScopedResource() } - - let extracted: String? - if url.pathExtension.lowercased() == "pdf" { - extracted = extractTextFromPDF(url: url) - } else { - extracted = try? String(contentsOf: url, encoding: .utf8) - } - - if let text = extracted, - !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - messages.append(ChatMessage( - text: text.trimmingCharacters(in: .whitespacesAndNewlines), - isFromUser: true - )) - } + @State private var inputText: String = "" - case .failure(let error): - print("File import error: \(error)") - } - } + var body: some View { + VStack { + // ── your existing message list UI goes here ── - private func extractTextFromPDF(url: URL) -> String? { - guard let pdf = PDFDocument(url: url) else { return nil } - var text = "" - for i in 0.. String { - let callable = functions.httpsCallable("chat") - let chatHistory: [[String: String]] = messages.dropLast().map { msg in - ["role": msg.isFromUser ? "user" : "assistant", "content": msg.text] - } - let result = try await callable.call([ - "prompt": prompt, - "chat_history": chatHistory, - "language": targetLanguage, - ]) - if let data = result.data as? [String: Any], - let response = data["response"] as? String { - return response - } else if let data = result.data as? [String: Any], - let error = data["error"] as? String { - throw NSError(domain: "LexAI", code: -1, userInfo: [NSLocalizedDescriptionKey: error]) - } - throw NSError(domain: "LexAI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unable to parse response"]) + // 3. Your existing AI call here + fetchAIResponse(for: trimmed) } -} - -// MARK: - Message Bubble -private struct MessageBubbleView: View { - let message: ChatMessage - - var body: some View { - HStack(alignment: .top) { - if message.isFromUser { Spacer(minLength: 48) } - VStack(alignment: message.isFromUser ? .trailing : .leading, spacing: 4) { - Text(message.text) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(message.isFromUser ? Color("grape") : Color(.systemGray6)) - .foregroundStyle(message.isFromUser ? .white : .black) - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) - } - if !message.isFromUser { Spacer(minLength: 48) } - } + // MARK: - AI Response Handler + private func fetchAIResponse(for prompt: String) { + // ... your existing API call ... + // + // When the AI response arrives, add these two lines: + // messages.append(ChatMessage(text: responseText, isFromUser: false)) + // if let id = sessionID { vm?.updateSession(id: id, messages: messages) } } } - -#Preview { - @Previewable @State var selectedLanguage = "English" - return ChatView(selectedLanguage: $selectedLanguage) -} diff --git a/LexAI_iOS/LexAI_iOS/Views/ContentView.swift b/LexAI_iOS/LexAI_iOS/Views/ContentView.swift index 64598c7..bc5abb4 100644 --- a/LexAI_iOS/LexAI_iOS/Views/ContentView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/ContentView.swift @@ -9,16 +9,15 @@ import SwiftUI struct ContentView: View { - @StateObject var firebaseManager = FirebaseManager() + @StateObject var authManager = FirebaseManager() var body: some View { Group { - if firebaseManager.isAuthenticated { + if authManager.isAuthenticated { HomeView() - .environmentObject(firebaseManager) } else { AuthView() - .environmentObject(firebaseManager) + .environmentObject(authManager) } } } diff --git a/LexAI_iOS/LexAI_iOS/Views/HomeView.swift b/LexAI_iOS/LexAI_iOS/Views/HomeView.swift index 0a5372c..5dfe70f 100644 --- a/LexAI_iOS/LexAI_iOS/Views/HomeView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/HomeView.swift @@ -1,41 +1,41 @@ import SwiftUI struct HomeView: View { - + @State private var isSidebarOpen = false + @State private var showToolbar = true @AppStorage("selectedLanguage") private var selectedLanguage: String = "English" @State private var showLanguageDropdown = false + @StateObject private var sidebarVM = SidebarViewModel() + @State private var messages: [ChatMessage] = [] + private let languages = ["English", "Spanish", "French", "Arabic", "German"] var body: some View { NavigationStack { GeometryReader { geo in ZStack(alignment: .leading) { - // MARK: Main content VStack { - ChatView(selectedLanguage: $selectedLanguage) + ChatView(messages: $messages, selectedLanguage: $selectedLanguage) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - // MARK: Dim overlay — tap to close if isSidebarOpen { - Color.black - .opacity(0.4) + Color.black.opacity(0.3) .ignoresSafeArea() - .onTapGesture { closeSidebar() } - .transition(.opacity) - .zIndex(1) + .onTapGesture { isSidebarOpen = false } } - // MARK: Sidebar — 80% width, slides in from left - SideBarView(isOpen: $isSidebarOpen, vm: sidebarVM) - .frame(width: geo.size.width * 0.80) - .offset(x: isSidebarOpen ? 0 : -(geo.size.width * 0.80)) - .shadow(color: .black.opacity(isSidebarOpen ? 0.2 : 0), radius: 16, x: 4, y: 0) - .zIndex(2) + SideBarView(isOpen: $isSidebarOpen, vm: sidebarVM, onNewChat: { + // SideBarView already called vm.newSession() internally — + // just clear the message thread to match the fresh session. + messages = [] + }) + .frame(width: geo.size.width * 0.80) + .offset(x: isSidebarOpen ? 0 : -(geo.size.width * 0.80)) + .shadow(color: .black.opacity(isSidebarOpen ? 0.2 : 0), radius: 16, x: 4, y: 0) + .animation(.easeInOut(duration: 0.28), value: isSidebarOpen) - // MARK: Language dropdown if showLanguageDropdown { VStack(alignment: .leading, spacing: 0) { ForEach(languages, id: \.self) { language in @@ -63,23 +63,21 @@ struct HomeView: View { .padding(.top, 8) .padding(.trailing, 16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) - .zIndex(3) + .animation(.easeInOut(duration: 0.2), value: showLanguageDropdown) } } - .animation(.easeInOut(duration: 0.28), value: isSidebarOpen) } .toolbar { ToolbarItem(placement: .navigationBarLeading) { if !isSidebarOpen { - Button { openSidebar() } label: { + Button { isSidebarOpen = true } label: { Image(systemName: "line.horizontal.3") } } } ToolbarItem(placement: .navigationBarTrailing) { Button { showLanguageDropdown.toggle() } label: { - Image(systemName: "globe") - .foregroundColor(Color("grape")) + Image(systemName: "globe").foregroundColor(Color("grape")) } } } @@ -88,14 +86,6 @@ struct HomeView: View { } } } - - private func openSidebar() { - withAnimation(.easeInOut(duration: 0.28)) { isSidebarOpen = true } - } - - private func closeSidebar() { - withAnimation(.easeInOut(duration: 0.28)) { isSidebarOpen = false } - } } #Preview { diff --git a/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift b/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift index 7df0667..f35ed43 100644 --- a/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift @@ -1,4 +1,4 @@ -// // SideBarView.swift +// SideBarView.swift // LexAI_iOS // // Sprint 3 update (Sidebar UI refinement) — Sara Al-hachami 03/31/26 @@ -119,7 +119,9 @@ final class SidebarViewModel: ObservableObject { let startOf7Days = cal.date(byAdding: .day, value: -7, to: startOfToday)! let startOf30Days = cal.date(byAdding: .day, value: -30, to: startOfToday)! - let active = filteredActiveSessions.sorted { $0.createdAt > $1.createdAt } + let all = filteredActiveSessions.sorted { $0.createdAt > $1.createdAt } + let pinned = all.filter { $0.isPinned } + let rest = all.filter { !$0.isPinned } var today: [ChatSession] = [] var yesterday: [ChatSession] = [] @@ -127,7 +129,7 @@ final class SidebarViewModel: ObservableObject { var month: [ChatSession] = [] var older: [ChatSession] = [] - for s in active { + for s in rest { if s.createdAt >= startOfToday { today.append(s) } else if s.createdAt >= startOfYesterday { yesterday.append(s) } else if s.createdAt >= startOf7Days { week.append(s) } @@ -136,6 +138,7 @@ final class SidebarViewModel: ObservableObject { } var groups: [(label: String, items: [ChatSession])] = [] + if !pinned.isEmpty { groups.append(("Pinned", pinned)) } if !today.isEmpty { groups.append(("Today", today)) } if !yesterday.isEmpty { groups.append(("Yesterday", yesterday)) } if !week.isEmpty { groups.append(("Previous 7 Days", week)) } @@ -162,7 +165,7 @@ final class SidebarViewModel: ObservableObject { @discardableResult func newSession(tag: SessionTag? = nil) -> ChatSession { - let s = ChatSession(title: "New Conversation", preview: "", tag: tag) + let s = ChatSession(id: UUID(), title: "New Conversation", preview: "", tag: tag) sessions.insert(s, at: 0) activeSessionID = s.id return s @@ -211,9 +214,11 @@ final class SidebarViewModel: ObservableObject { func updateSession(id: UUID, messages: [ChatMessage]) { guard let i = sessions.firstIndex(where: { $0.id == id }) else { return } sessions[i].messages = messages + // Title: use the first user message as-is (truncated to 50 chars). + // Only overwrite while the session still carries the default placeholder. if sessions[i].title == "New Conversation", let firstUserMsg = messages.first(where: { $0.isFromUser }) { - sessions[i].title = generateTitle(from: firstUserMsg.text) + sessions[i].title = String(firstUserMsg.text.prefix(50)) } if let lastAI = messages.last(where: { !$0.isFromUser }) { sessions[i].preview = String(lastAI.text.prefix(60)) @@ -236,22 +241,15 @@ final class SidebarViewModel: ObservableObject { } private func generateTitle(from query: String) -> String { - let lower = query.lowercased() - let prefixes: [String] = [ - "is there any way to ", "how do i ", "how can i ", "can i ", "can my ", - "what counts as ", "what is ", "what are ", "am i going to ", "am i ", - "do i need to ", "should i ", "will i ", "i need help with ", - "i want to know about ", "tell me about ", "help me with ", - "what happens if ", "is it legal to ", "is it illegal to " - ] - var trimmed = lower - for prefix in prefixes { - if trimmed.hasPrefix(prefix) { trimmed = String(trimmed.dropFirst(prefix.count)); break } + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 50 else { return trimmed } + // Truncate at the last word boundary before 50 chars + let index = trimmed.index(trimmed.startIndex, offsetBy: 50) + let cut = trimmed[.. Void)? = nil @State private var renamingSession: ChatSession? = nil @State private var renameText: String = "" @@ -290,22 +289,22 @@ struct SideBarView: View { // MARK: New Chat Button { vm.newSession() + onNewChat?() isOpen = false } label: { - HStack(spacing: 10) { + HStack(spacing: 8) { Image(systemName: "square.and.pencil") - .font(.system(size: 15)) - Text("New chat") - .font(.system(size: 15)) + .font(.system(size: 14, weight: .medium)) + Text("New Chat") + .font(.system(size: 15, weight: .medium)) Spacer() } .foregroundStyle(.primary) - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(Color(.systemGray5), in: RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal, 14) + .padding(.vertical, 11) } - .buttonStyle(.plain) - .padding(.horizontal, 12) + .buttonStyle(NewChatButtonStyle()) + .padding(.horizontal, 16) .padding(.bottom, 8) // MARK: Search @@ -422,6 +421,12 @@ struct SideBarView: View { private func sessionRow(_ session: ChatSession) -> some View { HStack { + if session.isPinned { + Image(systemName: "pin.fill") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(45)) + } Text(session.title) .font(.system(size: 14)) .foregroundStyle(vm.activeSessionID == session.id ? .primary : Color(.label).opacity(0.8)) @@ -439,6 +444,12 @@ struct SideBarView: View { isOpen = false } .contextMenu { + Button { + withAnimation { vm.togglePin(session) } + } label: { + Label(session.isPinned ? "Unpin" : "Pin", + systemImage: session.isPinned ? "pin.slash" : "pin") + } Button { renameText = session.title renamingSession = session @@ -458,6 +469,15 @@ struct SideBarView: View { Label("Delete", systemImage: "trash") } } + .swipeActions(edge: .leading, allowsFullSwipe: false) { + Button { + withAnimation { vm.togglePin(session) } + } label: { + Label(session.isPinned ? "Unpin" : "Pin", + systemImage: session.isPinned ? "pin.slash" : "pin") + } + .tint(.orange) + } } } From be67c945a18f487f38960d34bcc15879d6c9a6c0 Mon Sep 17 00:00:00 2001 From: Hassan Alkhafaji Date: Mon, 13 Apr 2026 16:25:36 -0400 Subject: [PATCH 3/4] fixed new chat button --- LexAI_iOS/LexAI_iOS/Views/ChatView.swift | 170 ++++++++++++++++---- LexAI_iOS/LexAI_iOS/Views/HomeView.swift | 6 +- LexAI_iOS/LexAI_iOS/Views/SideBarView.swift | 9 +- 3 files changed, 154 insertions(+), 31 deletions(-) diff --git a/LexAI_iOS/LexAI_iOS/Views/ChatView.swift b/LexAI_iOS/LexAI_iOS/Views/ChatView.swift index db64e86..3919691 100644 --- a/LexAI_iOS/LexAI_iOS/Views/ChatView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/ChatView.swift @@ -2,56 +2,172 @@ // LexAI_iOS import SwiftUI +import FirebaseFunctions -struct ChatView: View { +private let bottomAnchorId = "bottom" - // Bindings owned by HomeView — keeps HomeView call site unchanged +struct ChatView: View { @Binding var messages: [ChatMessage] @Binding var selectedLanguage: String - // Sidebar wiring — optional so the existing HomeView call compiles as-is. - // Pass vm + sessionID from HomeView when you're ready to enable title sync. var vm: SidebarViewModel? = nil var sessionID: UUID? = nil @State private var inputText: String = "" + @State private var isAwaitingReply = false + private let functions = Functions.functions() var body: some View { - VStack { - // ── your existing message list UI goes here ── + VStack(spacing: 0) { + Text("LexAI") + .font(.system(size: 46)) + .fontWeight(.semibold) + .foregroundStyle(Color("grape")) + .shadow(radius: 14, x: 0, y: 12) + + messageList + inputBar + } + .padding() + .background( + LinearGradient( + colors: [ + Color.white, + Color("grape").opacity(0.6), + Color("grape").opacity(0.9), + Color("grape") + ], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + ) + } + + private var messageList: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + if messages.isEmpty { + Text("Start a new chat") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.white.opacity(0.9)) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 24) + } + + ForEach(messages) { message in + MessageBubbleView(message: message) + } - // MARK: Send Button - Button("Send") { + Color.clear + .frame(height: 8) + .id(bottomAnchorId) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .scrollDismissesKeyboard(.interactively) + .onChange(of: messages.count) { _, _ in + withAnimation(.easeOut(duration: 0.25)) { + proxy.scrollTo(bottomAnchorId, anchor: .bottom) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var inputBar: some View { + HStack(alignment: .bottom, spacing: 12) { + TextField( + ChatPlaceholderText.placeholder(forSelectedLanguage: selectedLanguage), + text: $inputText, + axis: .vertical + ) + .textFieldStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color(.tertiarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .lineLimit(1...6) + + Button { sendMessage() + } label: { + Image(systemName: isAwaitingReply ? "clock.arrow.circlepath" : "arrow.up.circle.fill") + .resizable() + .frame(width: 35, height: 35) + .foregroundStyle(inputText.isEmpty ? Color.white.opacity(0.6) : Color.white) } + .disabled(inputText.isEmpty || isAwaitingReply) + .padding(.bottom, 4) } + .padding(.horizontal, 4) + .padding(.top) } - // MARK: - Send Message private func sendMessage() { - let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - - // 1. Append user message - let userMessage = ChatMessage(text: trimmed, isFromUser: true) - messages.append(userMessage) + let text = ChatInputValidator.trimmedMessage(inputText) + guard ChatInputValidator.shouldSendMessage(inputText) else { return } inputText = "" - // 2. Tell the sidebar — triggers title + preview update on first message - if let id = sessionID { - vm?.updateSession(id: id, messages: messages) + messages.append(ChatMessage(text: text, isFromUser: true)) + if let id = sessionID { vm?.updateSession(id: id, messages: messages) } + + Task { @MainActor in + isAwaitingReply = true + defer { isAwaitingReply = false } + + do { + let reply = try await generateAnswer(prompt: text, targetLanguage: selectedLanguage) + messages.append(ChatMessage(text: reply, isFromUser: false)) + if let id = sessionID { vm?.updateSession(id: id, messages: messages) } + } catch { + messages.append( + ChatMessage(text: ChatReplyErrorFormatter.replyErrorMessage(for: error), isFromUser: false) + ) + } } + } - // 3. Your existing AI call here - fetchAIResponse(for: trimmed) + private func generateAnswer(prompt: String, targetLanguage: String) async throws -> String { + let callable = functions.httpsCallable("chat") + let chatHistory: [[String: String]] = messages.dropLast().map { msg in + ["role": msg.isFromUser ? "user" : "assistant", "content": msg.text] + } + let result = try await callable.call([ + "prompt": prompt, + "chat_history": chatHistory, + "language": targetLanguage + ]) + + if let data = result.data as? [String: Any], + let response = data["response"] as? String { + return response + } else if let data = result.data as? [String: Any], + let error = data["error"] as? String { + throw NSError(domain: "LexAI", code: -1, userInfo: [NSLocalizedDescriptionKey: error]) + } + + throw NSError(domain: "LexAI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unable to parse response"]) } +} + +private struct MessageBubbleView: View { + let message: ChatMessage - // MARK: - AI Response Handler - private func fetchAIResponse(for prompt: String) { - // ... your existing API call ... - // - // When the AI response arrives, add these two lines: - // messages.append(ChatMessage(text: responseText, isFromUser: false)) - // if let id = sessionID { vm?.updateSession(id: id, messages: messages) } + var body: some View { + HStack(alignment: .top) { + if message.isFromUser { Spacer(minLength: 48) } + VStack(alignment: message.isFromUser ? .trailing : .leading, spacing: 4) { + Text(message.text) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(message.isFromUser ? Color("grape") : Color(.systemGray6)) + .foregroundStyle(message.isFromUser ? .white : .black) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + if !message.isFromUser { Spacer(minLength: 48) } + } } } diff --git a/LexAI_iOS/LexAI_iOS/Views/HomeView.swift b/LexAI_iOS/LexAI_iOS/Views/HomeView.swift index 5dfe70f..ca50e50 100644 --- a/LexAI_iOS/LexAI_iOS/Views/HomeView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/HomeView.swift @@ -9,6 +9,7 @@ struct HomeView: View { @StateObject private var sidebarVM = SidebarViewModel() @State private var messages: [ChatMessage] = [] + @State private var chatViewResetID = UUID() private let languages = ["English", "Spanish", "French", "Arabic", "German"] @@ -18,6 +19,7 @@ struct HomeView: View { ZStack(alignment: .leading) { VStack { ChatView(messages: $messages, selectedLanguage: $selectedLanguage) + .id(chatViewResetID) } if isSidebarOpen { @@ -27,9 +29,9 @@ struct HomeView: View { } SideBarView(isOpen: $isSidebarOpen, vm: sidebarVM, onNewChat: { - // SideBarView already called vm.newSession() internally — - // just clear the message thread to match the fresh session. + // Reset both chat data and local ChatView state for a true fresh thread. messages = [] + chatViewResetID = UUID() }) .frame(width: geo.size.width * 0.80) .offset(x: isSidebarOpen ? 0 : -(geo.size.width * 0.80)) diff --git a/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift b/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift index f35ed43..45c7ce1 100644 --- a/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift @@ -299,11 +299,16 @@ struct SideBarView: View { .font(.system(size: 15, weight: .medium)) Spacer() } - .foregroundStyle(.primary) + .foregroundStyle(.white) .padding(.horizontal, 14) .padding(.vertical, 11) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color("grape")) + ) + .shadow(color: Color("grape").opacity(0.28), radius: 8, x: 0, y: 4) } - .buttonStyle(NewChatButtonStyle()) + .buttonStyle(.plain) .padding(.horizontal, 16) .padding(.bottom, 8) From ebaee1b06a971889a7a17db14ce123b434e3d80c Mon Sep 17 00:00:00 2001 From: Hassan Alkhafaji Date: Mon, 13 Apr 2026 17:48:05 -0400 Subject: [PATCH 4/4] Fixed issue with sidebar branch not getting responses --- lexai-functions/.gitignore | 1 + lexai-functions/inspect_pinecone_metadata.py | 83 +++++++++ lexai-functions/main.py | 111 +++++++++--- lexai-functions/openai_translate.py | 167 +++++++++++++++++++ 4 files changed, 342 insertions(+), 20 deletions(-) create mode 100644 lexai-functions/inspect_pinecone_metadata.py create mode 100644 lexai-functions/openai_translate.py diff --git a/lexai-functions/.gitignore b/lexai-functions/.gitignore index 1609bab..5dc9bdf 100644 --- a/lexai-functions/.gitignore +++ b/lexai-functions/.gitignore @@ -3,4 +3,5 @@ __pycache__/ # Python virtual environment venv/ +.venv_inspect/ *.local diff --git a/lexai-functions/inspect_pinecone_metadata.py b/lexai-functions/inspect_pinecone_metadata.py new file mode 100644 index 0000000..de87855 --- /dev/null +++ b/lexai-functions/inspect_pinecone_metadata.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""One-off: print Pinecone match metadata keys + sample dict for michigan-legislation. + +Run from repo root or lexai-functions: + cd lexai-functions && python inspect_pinecone_metadata.py + +Requires PINECONE_API_KEY in environment or .env in this directory. +""" +from __future__ import annotations + +import json +import os +import sys + +from dotenv import load_dotenv +from pinecone import Pinecone + +load_dotenv() + +INDEX_NAME = "michigan-legislation" +EMBED_MODEL = "multilingual-e5-large" +QUERY = "michigan landlord tenant law" + + +def main() -> int: + api_key = (os.environ.get("PINECONE_API_KEY") or "").strip() + if not api_key: + print("PINECONE_API_KEY is not set", file=sys.stderr) + return 1 + + pc = Pinecone(api_key=api_key) + index = pc.Index(INDEX_NAME) + + try: + stats = index.describe_index_stats() + print("--- describe_index_stats ---") + print(json.dumps(stats.to_dict() if hasattr(stats, "to_dict") else str(stats), indent=2, default=str)) + except Exception as e: + print(f"describe_index_stats failed: {e}", file=sys.stderr) + + emb = pc.inference.embed( + model=EMBED_MODEL, + inputs=[QUERY], + parameters={"input_type": "query", "truncate": "END"}, + ) + vector = emb[0]["values"] + + results = index.query( + vector=vector, + top_k=5, + include_metadata=True, + ) + + matches = results.get("matches") or [] + print(f"\n--- query top_k=5 (no filter), matches={len(matches)} ---") + for i, m in enumerate(matches): + meta = m.get("metadata") + keys = sorted(meta.keys()) if isinstance(meta, dict) else [] + print(f"\nmatch[{i}] id={m.get('id')} score={m.get('score')} metadata_keys={keys}") + if isinstance(meta, dict): + print(json.dumps(meta, indent=2, default=str)[:4000]) + if len(json.dumps(meta, default=str)) > 4000: + print("... (truncated)") + + # Same query with chunk filter (matches embed_and_store chunk vectors) + results2 = index.query( + vector=vector, + top_k=5, + include_metadata=True, + filter={"chunk_idx": {"$gte": 0}}, + ) + matches2 = results2.get("matches") or [] + print(f"\n--- query with filter chunk_idx>=0, matches={len(matches2)} ---") + if matches2: + m0 = matches2[0] + meta0 = m0.get("metadata") or {} + print("first match metadata keys:", sorted(meta0.keys()) if isinstance(meta0, dict) else meta0) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lexai-functions/main.py b/lexai-functions/main.py index 1b64714..e824d10 100644 --- a/lexai-functions/main.py +++ b/lexai-functions/main.py @@ -1,29 +1,56 @@ -import json import os +import threading import time import requests from firebase_functions import https_fn +from firebase_functions.params import SecretParam, StringParam from firebase_admin import initialize_app from pinecone import Pinecone from dotenv import load_dotenv +from typing import Any load_dotenv() -# --- Config --- -PINECONE_API_KEY = os.getenv("PINECONE_API_KEY") -RUNPOD_API_KEY = os.getenv("RUNPOD_API_KEY") -RUNPOD_ENDPOINT_ID = os.getenv("RUNPOD_ENDPOINT_ID") +# --- Secret Manager params (injected into os.environ at request time) --- +OPENAI_API_KEY = SecretParam("OPENAI_API_KEY") +PINECONE_API_KEY = SecretParam("PINECONE_API_KEY") +RUNPOD_API_KEY = SecretParam("RUNPOD_API_KEY") + +# --- Deploy-time / runtime params --- +RUNPOD_ENDPOINT_ID = StringParam( + "RUNPOD_ENDPOINT_ID", + label="RunPod endpoint ID", + description="Serverless endpoint ID from the RunPod console.", +) + INDEX_NAME = "michigan-legislation" EMBED_MODEL = "multilingual-e5-large" initialize_app() -# Initialize Pinecone -pc = Pinecone(api_key=PINECONE_API_KEY) -index = pc.Index(INDEX_NAME) +_PINECONE_CLIENT: Pinecone | None = None +_PINECONE_INDEX: Any = None +_PINECONE_LOCK = threading.Lock() + + +def _get_pinecone() -> tuple[Pinecone, Any]: + """Lazy Pinecone init so secrets are available at request time, not import time.""" + global _PINECONE_CLIENT, _PINECONE_INDEX + if _PINECONE_CLIENT is not None and _PINECONE_INDEX is not None: + return _PINECONE_CLIENT, _PINECONE_INDEX + with _PINECONE_LOCK: + if _PINECONE_CLIENT is not None and _PINECONE_INDEX is not None: + return _PINECONE_CLIENT, _PINECONE_INDEX + api_key = (os.environ.get("PINECONE_API_KEY") or "").strip() + if not api_key: + raise RuntimeError("PINECONE_API_KEY is not set") + _PINECONE_CLIENT = Pinecone(api_key=api_key) + _PINECONE_INDEX = _PINECONE_CLIENT.Index(INDEX_NAME) + return _PINECONE_CLIENT, _PINECONE_INDEX def embed_query(query_text: str) -> list: + pc, _ = _get_pinecone() embeddings_response = pc.inference.embed( model=EMBED_MODEL, inputs=[query_text], @@ -32,20 +59,60 @@ def embed_query(query_text: str) -> list: return embeddings_response[0]["values"] +def _legislation_text_from_metadata(metadata: Any) -> str: + """Return first non-empty string from known Pinecone metadata text fields.""" + if not isinstance(metadata, dict): + return "" + for key in ( + "chunk_text", + "text", + "content", + "chunk", + "body", + "passage", + ): + val = metadata.get(key) + if isinstance(val, str) and val.strip(): + return val.strip() + return "" + + def query_pinecone(query_embedding: list, top_k: int = 5) -> list: - results = index.query( - vector=query_embedding, - top_k=top_k, - include_metadata=True, + _, index = _get_pinecone() + # Prefer chunk vectors (embed_and_store sets chunk_idx). If index has no chunk_idx, fall back to unfiltered query. + for use_chunk_filter in (True, False): + kwargs: dict[str, Any] = { + "vector": query_embedding, + "top_k": top_k, + "include_metadata": True, + } + if use_chunk_filter: + kwargs["filter"] = {"chunk_idx": {"$gte": 0}} + results = index.query(**kwargs) + chunks: list[str] = [] + for match in results.get("matches", []): + text = _legislation_text_from_metadata(match.get("metadata")) + if text: + chunks.append(text) + if chunks: + return chunks + raise RuntimeError( + "No usable legislation text in Pinecone results (metadata missing text fields or index empty)." ) - return [match["metadata"]["chunk_text"] for match in results["matches"]] def call_runpod(messages: list) -> str: + endpoint_id = (os.environ.get("RUNPOD_ENDPOINT_ID") or "").strip() + runpod_key = (os.environ.get("RUNPOD_API_KEY") or "").strip() + if not endpoint_id: + raise RuntimeError("RUNPOD_ENDPOINT_ID is not set") + if not runpod_key: + raise RuntimeError("RUNPOD_API_KEY is not set") + run_response = requests.post( - f"https://api.runpod.ai/v2/{RUNPOD_ENDPOINT_ID}/run", + f"https://api.runpod.ai/v2/{endpoint_id}/run", headers={ - "Authorization": f"Bearer {RUNPOD_API_KEY}", + "Authorization": f"Bearer {runpod_key}", "Content-Type": "application/json", }, json={ @@ -63,11 +130,11 @@ def call_runpod(messages: list) -> str: run_response.raise_for_status() job_id = run_response.json()["id"] - # Poll for completion, max 5 minutes - for _ in range(150): + # Poll for completion, max ~8 minutes (matches timeout_sec=540) + for _ in range(240): status_response = requests.get( - f"https://api.runpod.ai/v2/{RUNPOD_ENDPOINT_ID}/status/{job_id}", - headers={"Authorization": f"Bearer {RUNPOD_API_KEY}"}, + f"https://api.runpod.ai/v2/{endpoint_id}/status/{job_id}", + headers={"Authorization": f"Bearer {runpod_key}"}, ) status_response.raise_for_status() result = status_response.json() @@ -96,7 +163,11 @@ def call_runpod(messages: list) -> str: return "Error: Request timed out." -@https_fn.on_call(enforce_app_check=False, timeout_sec=300) +@https_fn.on_call( + enforce_app_check=False, + timeout_sec=540, + secrets=[OPENAI_API_KEY, PINECONE_API_KEY, RUNPOD_API_KEY], +) def chat(req: https_fn.CallableRequest) -> dict: try: prompt = req.data.get("prompt", "") diff --git a/lexai-functions/openai_translate.py b/lexai-functions/openai_translate.py new file mode 100644 index 0000000..b7f86d8 --- /dev/null +++ b/lexai-functions/openai_translate.py @@ -0,0 +1,167 @@ +"""OpenAI helpers wrapping a non-English UI around an English-only RunPod model. + +``normalize_to_english`` batches prior turns plus the current user message into one +structured JSON translation call (stable indices, translation-only system prompt). +``translate_english_to_ui_language`` maps the assistant's English reply back to the +client's display language. English UI skips both calls. + +Model id: ``OPENAI_TRANSLATION_MODEL`` (default ``gpt-5.1``). ``_chat_complete`` tries +``max_completion_tokens`` first, then falls back to ``max_tokens`` for older SDK shapes. +""" + +from __future__ import annotations + +import json +import os +from typing import Any + +from openai import OpenAI + +# System prompts: batch in (_BATCH_SYSTEM) vs single assistant string out (_OUT_SYSTEM_TEMPLATE). +_BATCH_SYSTEM = """You translate legal app chat fragments to English for a downstream English-only legal model. + +Rules: +- Output ONLY one JSON object. No markdown code fences. No commentary before or after JSON. +- Exact shape: {"items":[{"i":,"t":""}]} +- The same integer keys `i`, same count, and same order as in the input. +- Preserve statute identifiers, MCL / Michigan references, section numbers, docket-style numbers, and party names. +- Do not add legal analysis or advice — translation only. +""" + +_OUT_SYSTEM_TEMPLATE = """Translate the assistant message from English into {target_language}. + +Rules: +- Preserve citations, statute and section numbers, lists, and paragraph breaks where possible. +- Output only the translated text — no preamble, no quotes, no markdown wrapper.""" + + +def is_ui_english(language: str | None) -> bool: + """True when the client is using English; skips translation in/out.""" + s = (language or "").strip().lower() + return s in ("english", "en", "") + + +def _client() -> OpenAI: + """Configured OpenAI client; requires ``OPENAI_API_KEY`` in the environment.""" + api_key = os.environ.get("OPENAI_API_KEY") + if not api_key: + raise RuntimeError("OPENAI_API_KEY is not set") + return OpenAI(api_key=api_key) + + +def translation_model() -> str: + """Model id for translation calls (not the RunPod legal model).""" + return os.environ.get("OPENAI_TRANSLATION_MODEL", "gpt-5.1").strip() + + +def require_openai_if_translating(ui_language: str) -> None: + """Fail fast before RAG/RunPod when non-English UI is selected but no API key is bound.""" + if is_ui_english(ui_language): + return + if not os.environ.get("OPENAI_API_KEY"): + raise RuntimeError("OPENAI_API_KEY is required when the UI language is not English") + + +def _chat_complete( + client: OpenAI, + *, + messages: list[dict[str, str]], + json_object: bool, +) -> str: + """One chat.completions call; ``json_object=True`` for batch translate payloads.""" + model = translation_model() + kwargs: dict[str, Any] = { + "model": model, + "messages": messages, + "temperature": 0.2, + } + if json_object: + kwargs["response_format"] = {"type": "json_object"} + try: + # Newer OpenAI Python SDK uses max_completion_tokens. + resp = client.chat.completions.create(**kwargs, max_completion_tokens=8192) + except TypeError: + resp = client.chat.completions.create(**kwargs, max_tokens=8192) + content = resp.choices[0].message.content + return (content or "").strip() + + +def normalize_to_english( + chat_history: list[Any], + current_prompt: str, + ui_language: str, +) -> tuple[list[dict[str, str]], str]: + """Return ``(history_en, prompt_en)`` for RunPod. + + Non-English: one batched JSON object over all segments so roles stay aligned with + translated text. Validates that every input index ``i`` is returned exactly once. + """ + history: list[dict[str, str]] = [] + for m in chat_history: + if not isinstance(m, dict): + continue + role = str(m.get("role", "user")) + content = str(m.get("content", "")) + history.append({"role": role, "content": content}) + + prompt = str(current_prompt) + + if is_ui_english(ui_language): + return history, prompt + + client = _client() + payloads: list[dict[str, Any]] = [] + idx = 0 + for m in history: + payloads.append({"i": idx, "role": m["role"], "t": m["content"]}) + idx += 1 + payloads.append({"i": idx, "role": "user", "t": prompt}) + last_i = idx + + user_payload = json.dumps({"items": payloads}, ensure_ascii=False) + raw = _chat_complete( + client, + messages=[ + {"role": "system", "content": _BATCH_SYSTEM}, + {"role": "user", "content": user_payload}, + ], + json_object=True, + ) + data = json.loads(raw or "{}") + items = data.get("items") + if not isinstance(items, list) or len(items) != len(payloads): + raise RuntimeError("OpenAI translation returned invalid items length") + + by_i: dict[int, str] = {} + for x in items: + if not isinstance(x, dict) or "i" not in x: + continue + by_i[int(x["i"])] = str(x.get("t", "")) + + if len(by_i) != len(payloads): + raise RuntimeError("OpenAI translation missing segment keys") + + history_en: list[dict[str, str]] = [] + for p in payloads: + if p["i"] == last_i: + break + history_en.append({"role": str(p["role"]), "content": by_i[p["i"]]}) + prompt_en = by_i[last_i] + return history_en, prompt_en + + +def translate_english_to_ui_language(text: str, ui_language: str) -> str: + """Map RunPod's English assistant string to the UI language; no-op for English.""" + if is_ui_english(ui_language): + return text + client = _client() + target = (ui_language or "English").strip() + system = _OUT_SYSTEM_TEMPLATE.format(target_language=target) + return _chat_complete( + client, + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": text}, + ], + json_object=False, + )