1+ import json
2+ import os
3+ from pathlib import Path
4+ from typing import Optional , List
5+ from fastapi import APIRouter , HTTPException , Depends , Request
6+ from pydantic import BaseModel
7+ from openai import OpenAI
8+
9+ from .models import OnboardingChatRequest , OnboardingChatResponse , OnboardingStateResponse , OnboardingCompleteRequest
10+ from .supabase_client import supabase
11+
12+ router = APIRouter (prefix = "/onboarding" , tags = ["onboarding" ])
13+
14+ # Load questions
15+ QUESTIONS_PATH = Path (__file__ ).parent .parent / "data" / "questions.json"
16+ try :
17+ with open (QUESTIONS_PATH , "r" ) as f :
18+ QUESTIONS = json .load (f )
19+ except Exception as e :
20+ print (f"Error loading questions: { e } " )
21+ QUESTIONS = []
22+
23+ OPENROUTER_API_KEY = os .getenv ("OPENROUTER_API_KEY" )
24+ if not OPENROUTER_API_KEY :
25+ print ("Warning: OPENROUTER_API_KEY not set. Onboarding chat will fail." )
26+
27+ client = None
28+ if OPENROUTER_API_KEY :
29+ try :
30+ client = OpenAI (
31+ base_url = "https://openrouter.ai/api/v1" ,
32+ api_key = OPENROUTER_API_KEY ,
33+ )
34+ except Exception as e :
35+ print (f"Failed to init OpenAI/OpenRouter client: { e } " )
36+
37+ MODEL_NAME = "xiaomi/mimo-v2-flash:free"
38+
39+ async def get_current_user (request : Request ):
40+ auth_header = request .headers .get ("Authorization" )
41+ if not auth_header :
42+ raise HTTPException (status_code = 401 , detail = "Missing Authorization header" )
43+
44+ token = auth_header .replace ("Bearer " , "" )
45+ try :
46+ user_response = supabase .auth .get_user (token )
47+ if not user_response .user :
48+ raise HTTPException (status_code = 401 , detail = "Invalid token" )
49+ return user_response .user
50+ except Exception as e :
51+ print (f"Auth error: { e } " )
52+ raise HTTPException (status_code = 401 , detail = "Invalid authentication" )
53+
54+ @router .get ("/state" , response_model = OnboardingStateResponse )
55+ async def get_onboarding_state (user = Depends (get_current_user )):
56+ # Find active onboarding conversation
57+ response = supabase .table ("conversations" )\
58+ .select ("*" )\
59+ .eq ("participant_a" , user .id )\
60+ .eq ("is_onboarding" , True )\
61+ .order ("created_at" , desc = True )\
62+ .limit (1 )\
63+ .execute ()
64+
65+ if response .data :
66+ conv = response .data [0 ]
67+ return {
68+ "history" : conv .get ("transcript" , []),
69+ "conversation_id" : conv ["id" ],
70+ "is_completed" : False
71+ }
72+
73+ return {
74+ "history" : [],
75+ "conversation_id" : None ,
76+ "is_completed" : False
77+ }
78+
79+ @router .post ("/chat" , response_model = OnboardingChatResponse )
80+ async def chat_onboarding (req : OnboardingChatRequest , user = Depends (get_current_user )):
81+ if not client :
82+ raise HTTPException (status_code = 503 , detail = "AI service unavailable" )
83+
84+ conversation_id = req .conversation_id
85+ transcript = []
86+
87+ # 1. Retrieve or Create Conversation
88+ if conversation_id :
89+ res = supabase .table ("conversations" ).select ("*" ).eq ("id" , conversation_id ).single ().execute ()
90+ if not res .data :
91+ raise HTTPException (status_code = 404 , detail = "Conversation not found" )
92+ if res .data ["participant_a" ] != user .id :
93+ raise HTTPException (status_code = 403 , detail = "Not your conversation" )
94+ transcript = res .data .get ("transcript" , [])
95+ else :
96+ res = supabase .table ("conversations" )\
97+ .select ("*" )\
98+ .eq ("participant_a" , user .id )\
99+ .eq ("is_onboarding" , True )\
100+ .order ("created_at" , desc = True )\
101+ .limit (1 )\
102+ .execute ()
103+
104+ if res .data :
105+ conv = res .data [0 ]
106+ conversation_id = conv ["id" ]
107+ transcript = conv .get ("transcript" , [])
108+ else :
109+ new_conv = supabase .table ("conversations" ).insert ({
110+ "participant_a" : user .id ,
111+ "is_onboarding" : True ,
112+ "transcript" : []
113+ }).execute ()
114+ conversation_id = new_conv .data [0 ]["id" ]
115+ transcript = []
116+
117+ # 2. Append User Message
118+ if req .message != "[START]" :
119+ user_msg_obj = {"role" : "user" , "content" : req .message }
120+ transcript .append (user_msg_obj )
121+
122+ # 3. Construct LLM Prompt
123+ system_instruction = f"""
124+ You are a friendly, casual interviewer for a virtual world called 'Avatar World'.
125+ Your goal is to welcome the new user and get to know them by getting answers to the following questions.
126+
127+ REQUIRED QUESTIONS:
128+ { json .dumps (QUESTIONS , indent = 2 )}
129+
130+ INSTRUCTIONS:
131+ 1. Ask these questions ONE BY ONE. Do not dump them all at once.
132+ 2. Maintain a conversational flow. React to their answers (e.g., "Oh, that's cool!", "I love pizza too!").
133+ 3. You can change the order if it flows better, but ensure all are covered eventually.
134+ 4. Keep your responses concise (1-2 sentences usually).
135+ 5. If the user asks you questions, answer briefly and steer back to the interview.
136+ 6. When you are satisfied that you have answers to ALL specific questions (or the user has declined to answer enough times),
137+ you MUST signal completion by calling the 'end_interview' tool.
138+
139+ Current Progress:
140+ Review the transcript below. See which questions have been answered. Ask the next one.
141+ """
142+
143+ messages = [{"role" : "system" , "content" : system_instruction }]
144+ # Append transcript messages
145+ # Ensure roles are 'user' or 'assistant'. OpenRouter/OpenAI expects 'assistant' not 'model'.
146+ for msg in transcript :
147+ # My transcript uses 'assistant' internally, so it's fine.
148+ messages .append (msg )
149+
150+ # Define the tool
151+ tools = [
152+ {
153+ "type" : "function" ,
154+ "function" : {
155+ "name" : "end_interview" ,
156+ "description" : "Call this when all questions have been answered to finish the onboarding." ,
157+ "parameters" : {
158+ "type" : "object" ,
159+ "properties" : {},
160+ "required" : []
161+ }
162+ }
163+ }
164+ ]
165+
166+ try :
167+ completion = client .chat .completions .create (
168+ model = MODEL_NAME ,
169+ messages = messages ,
170+ tools = tools ,
171+ tool_choice = "auto"
172+ )
173+ except Exception as e :
174+ print (f"OpenRouter API Error: { e } " )
175+ return OnboardingChatResponse (
176+ response = "I'm having a bit of trouble connecting to my brain right now. Can you say that again?" ,
177+ conversation_id = conversation_id ,
178+ status = "active"
179+ )
180+
181+ # 5. Process Response
182+ ai_text = ""
183+ status = "active"
184+
185+ response_message = completion .choices [0 ].message
186+
187+ # Check for tool calls
188+ if response_message .tool_calls :
189+ # Check if it's the right tool
190+ for tool_call in response_message .tool_calls :
191+ if tool_call .function .name == "end_interview" :
192+ status = "completed"
193+ ai_text = "Thanks! That's everything I needed. Enjoy the world!"
194+ break
195+
196+ if status != "completed" :
197+ ai_text = response_message .content or "Hmm, I didn't catch that."
198+
199+ # 6. Save AI Response
200+ ai_msg_obj = {"role" : "assistant" , "content" : ai_text }
201+ transcript .append (ai_msg_obj )
202+
203+ supabase .table ("conversations" ).update ({
204+ "transcript" : transcript ,
205+ "updated_at" : "now()"
206+ }).eq ("id" , conversation_id ).execute ()
207+
208+ return OnboardingChatResponse (
209+ response = ai_text ,
210+ conversation_id = conversation_id ,
211+ status = status
212+ )
213+
214+ @router .post ("/complete" )
215+ async def complete_onboarding (req : OnboardingCompleteRequest , user = Depends (get_current_user )):
216+ if not client :
217+ raise HTTPException (status_code = 503 , detail = "AI service unavailable" )
218+
219+ # 1. Fetch Transcript
220+ res = supabase .table ("conversations" ).select ("*" ).eq ("id" , req .conversation_id ).single ().execute ()
221+ if not res .data :
222+ raise HTTPException (status_code = 404 , detail = "Conversation not found" )
223+
224+ conversation = res .data
225+ transcript = conversation .get ("transcript" , [])
226+
227+ # 2. Generate Memory Summary
228+ summary_prompt = f"""
229+ Analyze the following onboarding transcript for user '{ user .id } '.
230+
231+ Transcript:
232+ { json .dumps (transcript )}
233+
234+ Task:
235+ 1. Extract key facts (Name, Job, Hobbies, etc.).
236+ 2. Analyze their speaking style (Formal/Casual, Emoji usage, Length).
237+ 3. Create a concise summary paragraph.
238+
239+ Output JSON:
240+ {{
241+ "facts": {{ ... }},
242+ "style": "...",
243+ "summary": "..."
244+ }}
245+ """
246+
247+ try :
248+ completion = client .chat .completions .create (
249+ model = MODEL_NAME ,
250+ messages = [
251+ {"role" : "system" , "content" : "You are a helpful assistant that outputs JSON." },
252+ {"role" : "user" , "content" : summary_prompt }
253+ ],
254+ response_format = {"type" : "json_object" }
255+ )
256+ content = completion .choices [0 ].message .content
257+ summary_data = json .loads (content )
258+ summary_text = summary_data .get ("summary" , "New user joined the world." )
259+ except Exception as e :
260+ print (f"Summary generation failed: { e } " )
261+ summary_text = "User completed onboarding."
262+
263+ # 3. Save Memory
264+ supabase .table ("memories" ).insert ({
265+ "conversation_id" : req .conversation_id ,
266+ "owner_id" : user .id ,
267+ "partner_id" : None ,
268+ "summary" : summary_text ,
269+ "conversation_score" : 10
270+ }).execute ()
271+
272+ # 4. Update User Metadata
273+ try :
274+ supabase .auth .admin .update_user_by_id (
275+ user .id ,
276+ {"user_metadata" : {"onboarding_completed" : True }}
277+ )
278+ except Exception as e :
279+ print (f"Failed to update user metadata: { e } " )
280+ # Note: If service key is invalid/missing rights, this fails.
281+ raise HTTPException (status_code = 500 , detail = "Failed to finalize onboarding." )
282+
283+ return {"ok" : True }
0 commit comments