SAAHMATHWORKS commited on
Commit
8badae9
·
1 Parent(s): ca65f51

fix interrupt and streaming

Browse files
api/main.py CHANGED
@@ -1,28 +1,23 @@
1
  # api/main.py
2
- # Add project root to Python path
3
  import sys
4
  from pathlib import Path
5
  sys.path.insert(0, str(Path(__file__).parent.parent))
6
 
7
- from typing import Optional, Any
8
  from contextlib import asynccontextmanager
9
- from fastapi import FastAPI, Query, HTTPException
10
- from fastapi.responses import StreamingResponse, HTMLResponse
11
  from fastapi.middleware.cors import CORSMiddleware
12
- from langchain_core.messages import AIMessageChunk
13
- from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
14
  import json
15
  from uuid import uuid4
16
  import logging
17
  import os
18
  import asyncio
 
19
 
20
  # Import your existing system
21
  from core.system_initializer import setup_system
22
- from models.state_models import MultiCountryLegalState
23
-
24
- # utils functions
25
- from utils.helpers import message_obj_to_dict, dict_to_message_obj
26
 
27
  # Setup logging
28
  logging.basicConfig(level=logging.INFO)
@@ -35,375 +30,663 @@ system_initialized = False
35
 
36
 
37
  # ============================================================================
38
- # CRITICAL: Safe JSON Serialization Utilities
39
  # ============================================================================
40
- class SafeJSONEncoder(json.JSONEncoder):
41
- """
42
- Custom JSON encoder that safely handles Pydantic models and other non-serializable objects.
43
- This is the ultimate fallback for any serialization issues.
44
- """
45
- def default(self, obj):
46
- # Handle Pydantic models
47
- if hasattr(obj, 'model_dump'):
48
- return obj.model_dump()
49
- if hasattr(obj, 'dict'):
50
- return obj.dict()
51
-
52
- # Handle LangChain messages
53
- if isinstance(obj, BaseMessage):
54
- return {
55
- "role": "assistant" if isinstance(obj, AIMessage) else "user",
56
- "content": obj.content if hasattr(obj, 'content') else str(obj),
57
- "meta": getattr(obj, "additional_kwargs", {}),
58
- }
59
-
60
- # Handle sets
61
- if isinstance(obj, set):
62
- return list(obj)
63
-
64
- # Handle bytes
65
- if isinstance(obj, bytes):
66
- return obj.decode('utf-8', errors='ignore')
67
-
68
- # Fallback: convert to string
69
- try:
70
- return str(obj)
71
- except Exception:
72
- return f"<Unserializable: {type(obj).__name__}>"
73
 
 
 
 
74
 
75
- def safe_json_dumps(obj: Any) -> str:
76
- """
77
- Safely convert any object to JSON string with multiple fallback strategies.
78
- """
79
- try:
80
- # Try standard JSON encoding first
81
- return json.dumps(obj)
82
- except (TypeError, ValueError):
83
- try:
84
- # Try with custom encoder
85
- return json.dumps(obj, cls=SafeJSONEncoder)
86
- except Exception:
87
- try:
88
- # Try with default=str fallback
89
- return json.dumps(obj, default=str)
90
- except Exception as e:
91
- # Ultimate fallback: return error message
92
- logger.error(f"Complete JSON serialization failure: {e}")
93
- return json.dumps({"error": "serialization_failed", "message": str(e)})
94
- # ============================================================================
95
 
96
 
 
 
 
97
  async def initialize_system():
98
  global chat_manager, graph, system_initialized
99
  try:
100
- # Check for required environment variables based on YOUR settings
101
- required_vars = ['OPENAI_API_KEY', 'MONGO_URI', 'NEON_DB_URL', 'NEON_END_POINT']
102
- missing_vars = [var for var in required_vars if not os.getenv(var)]
103
-
104
- if missing_vars:
105
- logger.warning(f"⚠️ Missing environment variables: {missing_vars}")
106
- logger.warning("System will start but may not function properly")
107
-
108
  system = await setup_system()
109
  chat_manager = system["chat_manager"]
110
  graph = system["graph"]
111
  system_initialized = True
112
- logger.info("✅ Legal assistant system initialized for Hugging Face")
113
  except Exception as e:
114
  logger.error(f"❌ Failed to initialize system: {e}")
115
  system_initialized = False
116
 
117
  @asynccontextmanager
118
  async def lifespan(app: FastAPI):
119
- """Modern lifespan event handler"""
120
- # Startup logic
121
  logger.info("🚀 Starting Legal Assistant API...")
122
-
123
- # Initialize system in background
124
  initialization_task = asyncio.create_task(initialize_system())
125
-
126
- yield # App runs here
127
-
128
- # Shutdown logic
129
- logger.info("🛑 Shutting down Legal Assistant API...")
130
  initialization_task.cancel()
131
  try:
132
  await initialization_task
133
  except asyncio.CancelledError:
134
  pass
135
 
 
 
 
 
136
  app = FastAPI(
137
  title="Legal Assistant API",
138
- version="1.0.0",
139
- description="Multi-country legal RAG system for Benin and Madagascar",
140
- docs_url="/docs",
141
- redoc_url="/redoc",
142
  lifespan=lifespan
143
  )
144
 
145
- # Add CORS middleware
146
  app.add_middleware(
147
  CORSMiddleware,
148
- allow_origins=["*"],
149
  allow_credentials=True,
150
- allow_methods=["*"],
151
- allow_headers=["*"],
152
  )
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  @app.get("/", response_class=HTMLResponse)
155
  async def read_root():
156
- """Simple homepage for better UX"""
157
  return """
158
  <html>
159
  <head>
160
- <title>Legal Assistant API</title>
 
161
  <style>
162
- body { font-family: Arial, sans-serif; margin: 40px; }
163
- .container { max-width: 800px; margin: 0 auto; }
164
- .card { border: 1px solid #ddd; padding: 20px; margin: 10px 0; border-radius: 8px; }
165
- .status-ready { color: green; }
166
- .status-starting { color: orange; }
167
- .status-error { color: red; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  </style>
169
  </head>
170
  <body>
171
  <div class="container">
172
- <h1>🧑‍⚖️ Legal Assistant API</h1>
173
- <p>Multi-country legal RAG system for Benin and Madagascar</p>
174
-
175
- <div class="card">
176
- <h3>📚 Available Endpoints</h3>
177
- <ul>
178
- <li><a href="/docs">API Documentation</a></li>
179
- <li><a href="/health">Health Check</a></li>
180
- <li><strong>GET /chat</strong> - Streaming chat</li>
181
- <li><strong>GET /sessions/{id}/history</strong> - Conversation history</li>
182
- </ul>
183
  </div>
184
 
185
- <div class="card">
186
- <h3>🔧 System Status</h3>
187
- <div id="status">
188
- <p>Loading system status...</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  </div>
190
  </div>
191
-
192
- <script>
193
- async function updateStatus() {
194
- try {
195
- const response = await fetch('/health');
196
- const data = await response.json();
197
-
198
- const statusEl = document.getElementById('status');
199
- let statusClass = 'status-starting';
200
- let statusText = '🔄 Starting...';
201
-
202
- if (data.system_initialized) {
203
- statusClass = 'status-ready';
204
- statusText = '✅ System Ready';
205
- } else if (data.status === 'error') {
206
- statusClass = 'status-error';
207
- statusText = '❌ System Error';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  }
209
-
210
- statusEl.innerHTML = `
211
- <p class="${statusClass}"><strong>${statusText}</strong></p>
212
- <p><strong>MongoDB:</strong> ${data.mongodb_connected ? '✅ Connected' : '❌ Disconnected'}</p>
213
- <p><strong>Countries:</strong> ${data.available_countries?.join(', ') || 'Loading...'}</p>
214
- <p><strong>OpenAI:</strong> ${data.openai_configured ? '✅ Configured' : '❌ Not Configured'}</p>
215
- `;
216
- } catch (error) {
217
- document.getElementById('status').innerHTML =
218
- '<p class="status-error">❌ Failed to load system status</p>';
219
  }
 
 
 
 
 
 
 
 
220
  }
221
-
222
- updateStatus();
223
- setInterval(updateStatus, 5000);
224
- </script>
225
- </div>
 
 
 
 
 
 
 
 
226
  </body>
227
  </html>
228
  """
229
 
230
  @app.get("/health")
231
  async def health_check():
232
- """Enhanced health check with your specific environment variables"""
233
  return {
234
  "status": "healthy" if system_initialized else "starting",
235
  "system_initialized": system_initialized,
236
- "service": "Legal Assistant API",
237
- "available_countries": ["benin", "madagascar"] if system_initialized else [],
238
- "mongodb_connected": system_initialized and bool(os.getenv("MONGO_URI")),
239
- "openai_configured": bool(os.getenv("OPENAI_API_KEY")),
240
- "neon_postgres_configured": bool(os.getenv("NEON_END_POINT")),
241
- "missing_variables": [var for var in ['OPENAI_API_KEY', 'MONGO_URI', 'NEON_DB_URL', 'NEON_END_POINT'] if not os.getenv(var)],
242
- }
243
-
244
- def serialize_ai_message_chunk(chunk):
245
- """Serialize AI message chunks for streaming"""
246
- if isinstance(chunk, AIMessageChunk):
247
- return chunk.content
248
- else:
249
- raise TypeError(
250
- f"Object of type {type(chunk).__name__} is not correctly formatted for serialisation"
251
- )
252
-
253
- async def generate_legal_chat_responses(message: str, session_id: Optional[str] = None) -> str:
254
- if not session_id:
255
- session_id = f"api_{uuid4()}"
256
-
257
- # CRITICAL FIX: Create input state as a dictionary first, then convert to Pydantic model
258
- # This ensures proper serialization for PostgreSQL checkpointing
259
- input_state_dict = {
260
- "messages": [{"role": "user", "content": message, "meta": {}}],
261
- "legal_context": {
262
- "jurisdiction": "Unknown",
263
- "user_type": "general",
264
- "document_type": "legal",
265
- "detected_country": "unknown"
266
- },
267
- "session_id": session_id,
268
- "router_decision": None,
269
- "search_results": None,
270
- "route_explanation": None,
271
- "last_search_query": None,
272
- "detected_articles": [],
273
- "supplemental_message": "",
274
- "country": None,
275
- "assistance_requested": False,
276
- "user_email": None,
277
- "assistance_description": None,
278
- "email_status": None,
279
- "assistance_step": None,
280
- "pending_assistance_data": {},
281
- "repair_type": None,
282
- "original_query": None,
283
- "misunderstanding_count": 0,
284
- "primary_intent": None,
285
- "approval_status": None,
286
- "approval_reason": None,
287
- "approved_by": None,
288
- "approval_timestamp": None,
289
- "summary_generated": False,
290
- "last_summary_timestamp": None,
291
- "search_metadata": {}
292
- }
293
-
294
- # Convert to Pydantic model (this will use our custom model_dump for serialization)
295
- input_state = MultiCountryLegalState(**input_state_dict)
296
-
297
- config = {
298
- "configurable": {
299
- "thread_id": session_id
300
- }
301
  }
302
 
303
- # Stream events from the graph
304
- events = graph.astream_events(
305
- input_state, # Pass the Pydantic model directly
306
- version="v2",
307
- config=config
308
- )
309
-
310
- current_content = ""
311
- current_node = ""
312
- final_state = None
313
-
314
  try:
315
- async for event in events:
316
- event_type = event["event"]
317
- node_name = event.get("name", "")
 
 
 
318
 
319
- if node_name != current_node:
320
- current_node = node_name
321
- yield f"data: {safe_json_dumps({'type': 'node_transition', 'node': node_name})}\n\n"
322
-
323
- if event_type == "on_chat_model_stream":
324
- chunk_content = serialize_ai_message_chunk(event["data"]["chunk"])
325
- current_content += chunk_content
326
- yield f"data: {safe_json_dumps({'type': 'content', 'content': chunk_content})}\n\n"
327
-
328
- elif event_type == "on_chat_model_end":
329
- yield f"data: {safe_json_dumps({'type': 'content_end'})}\n\n"
330
-
331
- elif event_type == "on_chain_start" and "retrieval" in node_name:
332
- country = node_name.replace("_retrieval", "")
333
- yield f"data: {safe_json_dumps({'type': 'search_start', 'country': country})}\n\n"
334
-
335
- elif event_type == "on_chain_end" and "retrieval" in node_name:
336
- country = node_name.replace("_retrieval", "")
337
- yield f"data: {safe_json_dumps({'type': 'search_end', 'country': country})}\n\n"
338
-
339
- elif event_type == "on_tool_end":
340
- tool_name = event["name"]
341
- yield f"data: {safe_json_dumps({'type': 'tool_complete', 'tool': tool_name})}\n\n"
342
-
343
- elif event_type == "on_graph_end":
344
- # Capture and convert the final state - WITH SAFE SERIALIZATION
345
- try:
346
- state = event.get("data", {}).get("output")
347
- if state:
348
- if isinstance(state, MultiCountryLegalState):
349
- final_state = state
350
- # Use our custom model_dump method for proper serialization
351
- state_dict = state.model_dump()
352
- elif isinstance(state, dict):
353
- state_dict = state
354
- else:
355
- # Fallback: convert to string
356
- state_dict = {"state": str(state)}
357
-
358
- yield f"data: {safe_json_dumps({'type': 'state', 'content': state_dict})}\n\n"
359
- except Exception as state_error:
360
- logger.warning(f"Could not serialize state: {state_error}")
361
- # Don't fail, just skip state output
362
-
363
- yield f"data: {safe_json_dumps({'type': 'graph_end'})}\n\n"
364
-
365
  except Exception as e:
366
- logger.error(f"Error in generate_legal_chat_responses: {e}", exc_info=True)
367
- yield f"data: {safe_json_dumps({'type': 'error', 'message': str(e)})}\n\n"
368
 
369
- # Yield final state if captured - WITH SAFE SERIALIZATION
370
- try:
371
- if final_state and isinstance(final_state, MultiCountryLegalState):
372
- final_state_dict = final_state.model_dump()
373
- yield f"data: {safe_json_dumps({'type': 'final_state', 'content': final_state_dict})}\n\n"
374
- except Exception as final_error:
375
- logger.warning(f"Could not serialize final state: {final_error}")
376
- # Don't fail, just skip final state output
377
-
378
- yield f"data: {safe_json_dumps({'type': 'end'})}\n\n"
379
-
380
-
381
- @app.get("/chat")
382
- async def chat_stream(
383
- message: str = Query(..., description="User message"),
384
- session_id: Optional[str] = Query(None, description="Existing session ID")
385
- ):
386
- """Streaming chat endpoint with initialization check"""
387
- if not system_initialized:
388
- raise HTTPException(
389
- status_code=503,
390
- detail="System is still starting up. Please try again in a moment."
391
- )
392
 
393
  return StreamingResponse(
394
- generate_legal_chat_responses(message, session_id),
395
  media_type="text/event-stream",
396
  headers={
397
  "Cache-Control": "no-cache",
398
  "Connection": "keep-alive",
 
399
  }
400
  )
401
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
  @app.get("/sessions/{session_id}/history")
403
- async def get_conversation_history(session_id: str):
404
- """Get conversation history for a session"""
405
  if not chat_manager:
406
- return {"error": "System not initialized"}
407
 
408
  try:
409
  history = await chat_manager.get_conversation_history(session_id)
@@ -411,12 +694,11 @@ async def get_conversation_history(session_id: str):
411
  "session_id": session_id,
412
  "history": [
413
  {
414
- "role": msg.role if hasattr(msg, 'role') else msg.get('role', 'unknown'),
415
- "content": msg.content if hasattr(msg, 'content') else msg.get('content', ''),
416
- "timestamp": getattr(msg, 'timestamp', None)
417
  }
418
  for msg in history
419
  ]
420
  }
421
  except Exception as e:
422
- return {"error": str(e)}
 
1
  # api/main.py
 
2
  import sys
3
  from pathlib import Path
4
  sys.path.insert(0, str(Path(__file__).parent.parent))
5
 
6
+ from typing import Optional, Any, Dict, AsyncGenerator
7
  from contextlib import asynccontextmanager
8
+ from fastapi import FastAPI, Query, HTTPException, Body
9
+ from fastapi.responses import StreamingResponse, HTMLResponse, JSONResponse
10
  from fastapi.middleware.cors import CORSMiddleware
11
+ from pydantic import BaseModel
 
12
  import json
13
  from uuid import uuid4
14
  import logging
15
  import os
16
  import asyncio
17
+ from datetime import datetime
18
 
19
  # Import your existing system
20
  from core.system_initializer import setup_system
 
 
 
 
21
 
22
  # Setup logging
23
  logging.basicConfig(level=logging.INFO)
 
30
 
31
 
32
  # ============================================================================
33
+ # Pydantic Models
34
  # ============================================================================
35
+ class ChatRequest(BaseModel):
36
+ message: str
37
+ session_id: Optional[str] = None
38
+ stream: bool = False # Option to enable/disable streaming
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
+ class ApprovalRequest(BaseModel):
41
+ decision: str # "approve" or "reject"
42
+ reason: Optional[str] = None
43
 
44
+ class ChatResponse(BaseModel):
45
+ response: str
46
+ session_id: str
47
+ has_interrupt: bool = False
48
+ interrupt_type: Optional[str] = None
49
+ interrupt_data: Optional[Dict] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
 
52
+ # ============================================================================
53
+ # System Initialization
54
+ # ============================================================================
55
  async def initialize_system():
56
  global chat_manager, graph, system_initialized
57
  try:
 
 
 
 
 
 
 
 
58
  system = await setup_system()
59
  chat_manager = system["chat_manager"]
60
  graph = system["graph"]
61
  system_initialized = True
62
+ logger.info("✅ Legal assistant system initialized")
63
  except Exception as e:
64
  logger.error(f"❌ Failed to initialize system: {e}")
65
  system_initialized = False
66
 
67
  @asynccontextmanager
68
  async def lifespan(app: FastAPI):
 
 
69
  logger.info("🚀 Starting Legal Assistant API...")
 
 
70
  initialization_task = asyncio.create_task(initialize_system())
71
+ yield
72
+ logger.info("🛑 Shutting down...")
 
 
 
73
  initialization_task.cancel()
74
  try:
75
  await initialization_task
76
  except asyncio.CancelledError:
77
  pass
78
 
79
+
80
+ # ============================================================================
81
+ # FastAPI App
82
+ # ============================================================================
83
  app = FastAPI(
84
  title="Legal Assistant API",
85
+ version="2.0.0",
86
+ description="Multi-country legal RAG with streaming & human-in-the-loop",
 
 
87
  lifespan=lifespan
88
  )
89
 
 
90
  app.add_middleware(
91
  CORSMiddleware,
92
+ allow_origins=["*"],
93
  allow_credentials=True,
94
+ allow_methods=["*"],
95
+ allow_headers=["*"],
96
  )
97
 
98
+
99
+ # ============================================================================
100
+ # Streaming Helper
101
+ # ============================================================================
102
+ async def stream_chat_response(
103
+ message: str,
104
+ session_id: str
105
+ ) -> AsyncGenerator[str, None]:
106
+ """
107
+ Stream chat responses token by token.
108
+ If interrupt occurs, yields special interrupt event.
109
+ """
110
+ try:
111
+ # Check for pending interrupt first
112
+ if chat_manager.has_pending_interrupt(session_id):
113
+ interrupt_info = chat_manager.pending_interrupts.get(session_id, {})
114
+ interrupt_data_obj = interrupt_info.get("interrupt_data")
115
+
116
+ if hasattr(interrupt_data_obj, 'value'):
117
+ interrupt_value = interrupt_data_obj.value
118
+ else:
119
+ interrupt_value = {}
120
+
121
+ # Yield interrupt event
122
+ yield f"data: {json.dumps({'type': 'interrupt', 'data': interrupt_value})}\n\n"
123
+ return
124
+
125
+ # Buffer to collect response
126
+ response_buffer = []
127
+
128
+ # Process message
129
+ response = await chat_manager.chat(
130
+ message=message,
131
+ session_id=session_id,
132
+ interrupt_handler=None # Async mode
133
+ )
134
+
135
+ # Check if interrupt occurred during processing
136
+ if chat_manager.has_pending_interrupt(session_id):
137
+ interrupt_info = chat_manager.pending_interrupts.get(session_id, {})
138
+ interrupt_data_obj = interrupt_info.get("interrupt_data")
139
+
140
+ if hasattr(interrupt_data_obj, 'value'):
141
+ interrupt_value = interrupt_data_obj.value
142
+ else:
143
+ interrupt_value = {}
144
+
145
+ # Yield interrupt event
146
+ yield f"data: {json.dumps({'type': 'interrupt', 'data': interrupt_value})}\n\n"
147
+ return
148
+
149
+ # Stream response token by token (simulate streaming)
150
+ words = response.split()
151
+ for i, word in enumerate(words):
152
+ token = word + (" " if i < len(words) - 1 else "")
153
+ yield f"data: {json.dumps({'type': 'token', 'content': token})}\n\n"
154
+ await asyncio.sleep(0.02) # Small delay for visual effect
155
+
156
+ # Send completion event
157
+ yield f"data: {json.dumps({'type': 'done', 'session_id': session_id})}\n\n"
158
+
159
+ except Exception as e:
160
+ logger.exception(f"Error in streaming: {e}")
161
+ yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
162
+
163
+
164
+ # ============================================================================
165
+ # Routes
166
+ # ============================================================================
167
  @app.get("/", response_class=HTMLResponse)
168
  async def read_root():
169
+ """Interactive chat interface with streaming support"""
170
  return """
171
  <html>
172
  <head>
173
+ <title>Legal Assistant - Interactive Chat</title>
174
+ <meta charset="UTF-8">
175
  <style>
176
+ * { margin: 0; padding: 0; box-sizing: border-box; }
177
+ body {
178
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
179
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
180
+ min-height: 100vh;
181
+ padding: 20px;
182
+ }
183
+ .container {
184
+ max-width: 900px;
185
+ margin: 0 auto;
186
+ background: white;
187
+ border-radius: 16px;
188
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3);
189
+ overflow: hidden;
190
+ }
191
+ .header {
192
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
193
+ color: white;
194
+ padding: 30px;
195
+ text-align: center;
196
+ }
197
+ .header h1 { margin-bottom: 10px; }
198
+ .header p { opacity: 0.9; }
199
+ .chat-container { padding: 20px; }
200
+ #messages {
201
+ height: 500px;
202
+ overflow-y: auto;
203
+ padding: 20px;
204
+ background: #f8f9fa;
205
+ border-radius: 12px;
206
+ margin-bottom: 20px;
207
+ }
208
+ .message {
209
+ margin: 15px 0;
210
+ padding: 12px 18px;
211
+ border-radius: 18px;
212
+ max-width: 75%;
213
+ animation: slideIn 0.3s ease;
214
+ word-wrap: break-word;
215
+ }
216
+ @keyframes slideIn {
217
+ from { opacity: 0; transform: translateY(10px); }
218
+ to { opacity: 1; transform: translateY(0); }
219
+ }
220
+ .user-message {
221
+ background: #667eea;
222
+ color: white;
223
+ margin-left: auto;
224
+ border-bottom-right-radius: 4px;
225
+ }
226
+ .assistant-message {
227
+ background: white;
228
+ color: #333;
229
+ border: 1px solid #e0e0e0;
230
+ border-bottom-left-radius: 4px;
231
+ }
232
+ .system-message {
233
+ background: #fff3cd;
234
+ color: #856404;
235
+ border: 1px solid #ffc107;
236
+ text-align: center;
237
+ margin: 10px auto;
238
+ max-width: 90%;
239
+ }
240
+ .interrupt-panel {
241
+ background: #fff3cd;
242
+ border: 2px solid #ffc107;
243
+ border-radius: 12px;
244
+ padding: 20px;
245
+ margin: 20px 0;
246
+ animation: pulse 2s infinite;
247
+ }
248
+ @keyframes pulse {
249
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 193, 7, 0.4); }
250
+ 50% { box-shadow: 0 0 0 10px rgba(255, 193, 7, 0); }
251
+ }
252
+ .interrupt-panel h3 {
253
+ color: #856404;
254
+ margin-bottom: 15px;
255
+ }
256
+ .interrupt-details {
257
+ background: white;
258
+ padding: 15px;
259
+ border-radius: 8px;
260
+ margin: 15px 0;
261
+ }
262
+ .interrupt-details p {
263
+ margin: 8px 0;
264
+ color: #333;
265
+ }
266
+ .approval-buttons {
267
+ display: flex;
268
+ gap: 10px;
269
+ margin-top: 15px;
270
+ }
271
+ .btn {
272
+ flex: 1;
273
+ padding: 12px 24px;
274
+ border: none;
275
+ border-radius: 8px;
276
+ font-weight: bold;
277
+ cursor: pointer;
278
+ transition: all 0.3s;
279
+ font-size: 14px;
280
+ }
281
+ .btn-approve {
282
+ background: #4caf50;
283
+ color: white;
284
+ }
285
+ .btn-approve:hover {
286
+ background: #45a049;
287
+ transform: translateY(-2px);
288
+ box-shadow: 0 4px 12px rgba(76, 175, 80, 0.4);
289
+ }
290
+ .btn-reject {
291
+ background: #f44336;
292
+ color: white;
293
+ }
294
+ .btn-reject:hover {
295
+ background: #da190b;
296
+ transform: translateY(-2px);
297
+ box-shadow: 0 4px 12px rgba(244, 67, 54, 0.4);
298
+ }
299
+ .input-area {
300
+ display: flex;
301
+ gap: 10px;
302
+ padding: 10px;
303
+ background: #f8f9fa;
304
+ border-radius: 12px;
305
+ }
306
+ #message-input {
307
+ flex: 1;
308
+ padding: 12px 16px;
309
+ border: 2px solid #e0e0e0;
310
+ border-radius: 8px;
311
+ font-size: 14px;
312
+ transition: border 0.3s;
313
+ }
314
+ #message-input:focus {
315
+ outline: none;
316
+ border-color: #667eea;
317
+ }
318
+ #send-btn {
319
+ padding: 12px 32px;
320
+ background: #667eea;
321
+ color: white;
322
+ border: none;
323
+ border-radius: 8px;
324
+ font-weight: bold;
325
+ cursor: pointer;
326
+ transition: all 0.3s;
327
+ }
328
+ #send-btn:hover:not(:disabled) {
329
+ background: #5568d3;
330
+ transform: translateY(-2px);
331
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
332
+ }
333
+ #send-btn:disabled {
334
+ background: #ccc;
335
+ cursor: not-allowed;
336
+ transform: none;
337
+ }
338
+ .status-bar {
339
+ padding: 10px;
340
+ background: #f8f9fa;
341
+ border-radius: 8px;
342
+ margin-top: 10px;
343
+ display: flex;
344
+ justify-content: space-between;
345
+ font-size: 12px;
346
+ color: #666;
347
+ }
348
+ .typing-indicator {
349
+ display: none;
350
+ padding: 10px;
351
+ color: #666;
352
+ font-style: italic;
353
+ }
354
+ .typing-indicator.active {
355
+ display: block;
356
+ }
357
  </style>
358
  </head>
359
  <body>
360
  <div class="container">
361
+ <div class="header">
362
+ <h1>🧑‍⚖️ Legal Assistant</h1>
363
+ <p>Multi-country legal support for Benin & Madagascar</p>
 
 
 
 
 
 
 
 
364
  </div>
365
 
366
+ <div class="chat-container">
367
+ <div id="messages"></div>
368
+ <div id="interrupt-zone"></div>
369
+ <div class="typing-indicator" id="typing">Assistant is typing...</div>
370
+
371
+ <div class="input-area">
372
+ <input
373
+ type="text"
374
+ id="message-input"
375
+ placeholder="Type your legal question..."
376
+ autocomplete="off"
377
+ />
378
+ <button id="send-btn" onclick="sendMessage()">
379
+ Send
380
+ </button>
381
+ </div>
382
+
383
+ <div class="status-bar">
384
+ <span>Session: <strong id="session-id">Not started</strong></span>
385
+ <span id="status">System ready</span>
386
  </div>
387
  </div>
388
+ </div>
389
+
390
+ <script>
391
+ let sessionId = null;
392
+ let currentAssistantMessage = null;
393
+
394
+ function addMessage(content, type) {
395
+ const messagesDiv = document.getElementById('messages');
396
+ const messageDiv = document.createElement('div');
397
+ messageDiv.className = `message ${type}-message`;
398
+ messageDiv.textContent = content;
399
+ messagesDiv.appendChild(messageDiv);
400
+ messagesDiv.scrollTop = messagesDiv.scrollHeight;
401
+ return messageDiv;
402
+ }
403
+
404
+ function showInterrupt(data) {
405
+ const interruptZone = document.getElementById('interrupt-zone');
406
+ interruptZone.innerHTML = `
407
+ <div class="interrupt-panel">
408
+ <h3>🔒 Human Approval Required</h3>
409
+ <div class="interrupt-details">
410
+ <p><strong>📧 Email:</strong> ${data.user_email || 'N/A'}</p>
411
+ <p><strong>🌍 Country:</strong> ${data.country || 'N/A'}</p>
412
+ <p><strong>📝 Description:</strong> ${data.description || 'N/A'}</p>
413
+ </div>
414
+ <div class="approval-buttons">
415
+ <button class="btn btn-approve" onclick="handleApproval('approve')">
416
+ ✅ Approve Request
417
+ </button>
418
+ <button class="btn btn-reject" onclick="handleApproval('reject')">
419
+ ❌ Reject Request
420
+ </button>
421
+ </div>
422
+ </div>
423
+ `;
424
+ }
425
+
426
+ function hideInterrupt() {
427
+ document.getElementById('interrupt-zone').innerHTML = '';
428
+ }
429
+
430
+ async function handleApproval(decision) {
431
+ const reason = decision === 'approve'
432
+ ? 'Approved via web interface'
433
+ : 'Rejected via web interface';
434
+
435
+ try {
436
+ addMessage(`${decision === 'approve' ? '✅' : '❌'} Decision: ${decision}`, 'system');
437
+ hideInterrupt();
438
+
439
+ const response = await fetch(`/sessions/${sessionId}/approve`, {
440
+ method: 'POST',
441
+ headers: { 'Content-Type': 'application/json' },
442
+ body: JSON.stringify({ decision, reason })
443
+ });
444
+
445
+ const data = await response.json();
446
+ addMessage(data.response, 'assistant');
447
+
448
+ } catch (error) {
449
+ addMessage('Error: ' + error.message, 'system');
450
+ }
451
+ }
452
+
453
+ async function sendMessage() {
454
+ const input = document.getElementById('message-input');
455
+ const message = input.value.trim();
456
+
457
+ if (!message) return;
458
+
459
+ // Initialize session
460
+ if (!sessionId) {
461
+ sessionId = 'web_' + Date.now();
462
+ document.getElementById('session-id').textContent = sessionId;
463
+ }
464
+
465
+ // Add user message
466
+ addMessage(message, 'user');
467
+ input.value = '';
468
+
469
+ // Disable input
470
+ const sendBtn = document.getElementById('send-btn');
471
+ sendBtn.disabled = true;
472
+ input.disabled = true;
473
+
474
+ // Show typing indicator
475
+ document.getElementById('typing').classList.add('active');
476
+
477
+ try {
478
+ // Use streaming endpoint
479
+ const response = await fetch('/chat/stream', {
480
+ method: 'POST',
481
+ headers: { 'Content-Type': 'application/json' },
482
+ body: JSON.stringify({
483
+ message: message,
484
+ session_id: sessionId,
485
+ stream: true
486
+ })
487
+ });
488
+
489
+ const reader = response.body.getReader();
490
+ const decoder = new TextDecoder();
491
+ currentAssistantMessage = addMessage('', 'assistant');
492
+
493
+ while (true) {
494
+ const { value, done } = await reader.read();
495
+ if (done) break;
496
+
497
+ const chunk = decoder.decode(value);
498
+ const lines = chunk.split('\\n\\n');
499
+
500
+ for (const line of lines) {
501
+ if (line.startsWith('data: ')) {
502
+ try {
503
+ const data = JSON.parse(line.slice(6));
504
+
505
+ if (data.type === 'token') {
506
+ currentAssistantMessage.textContent += data.content;
507
+ } else if (data.type === 'interrupt') {
508
+ showInterrupt(data.data);
509
+ } else if (data.type === 'done') {
510
+ document.getElementById('status').textContent = 'Ready';
511
+ } else if (data.type === 'error') {
512
+ addMessage('Error: ' + data.message, 'system');
513
+ }
514
+ } catch (e) {
515
+ console.error('Parse error:', e);
516
+ }
517
+ }
518
  }
 
 
 
 
 
 
 
 
 
 
519
  }
520
+
521
+ } catch (error) {
522
+ addMessage('Error: ' + error.message, 'system');
523
+ } finally {
524
+ sendBtn.disabled = false;
525
+ input.disabled = false;
526
+ input.focus();
527
+ document.getElementById('typing').classList.remove('active');
528
  }
529
+ }
530
+
531
+ // Enter key to send
532
+ document.getElementById('message-input').addEventListener('keypress', (e) => {
533
+ if (e.key === 'Enter' && !e.shiftKey) {
534
+ e.preventDefault();
535
+ sendMessage();
536
+ }
537
+ });
538
+
539
+ // Focus input on load
540
+ document.getElementById('message-input').focus();
541
+ </script>
542
  </body>
543
  </html>
544
  """
545
 
546
  @app.get("/health")
547
  async def health_check():
548
+ """Health check endpoint"""
549
  return {
550
  "status": "healthy" if system_initialized else "starting",
551
  "system_initialized": system_initialized,
552
+ "timestamp": datetime.now().isoformat()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
553
  }
554
 
555
+ @app.post("/chat")
556
+ async def chat_simple(request: ChatRequest):
557
+ """
558
+ Simple non-streaming chat endpoint.
559
+ Best for handling interrupts - returns complete response.
560
+ """
561
+ if not system_initialized or not chat_manager:
562
+ raise HTTPException(status_code=503, detail="System initializing...")
563
+
 
 
564
  try:
565
+ session_id = request.session_id or f"api_{uuid4()}"
566
+
567
+ # Check for pending interrupt
568
+ if chat_manager.has_pending_interrupt(session_id):
569
+ interrupt_info = chat_manager.pending_interrupts.get(session_id, {})
570
+ interrupt_data_obj = interrupt_info.get("interrupt_data")
571
 
572
+ if hasattr(interrupt_data_obj, 'value'):
573
+ interrupt_value = interrupt_data_obj.value
574
+ else:
575
+ interrupt_value = {}
576
+
577
+ return ChatResponse(
578
+ response="⏸️ Pending approval request. Please approve/reject first.",
579
+ session_id=session_id,
580
+ has_interrupt=True,
581
+ interrupt_type="human_approval",
582
+ interrupt_data=interrupt_value
583
+ )
584
+
585
+ # Process message
586
+ response_text = await chat_manager.chat(
587
+ message=request.message,
588
+ session_id=session_id
589
+ )
590
+
591
+ # Check if interrupt occurred
592
+ has_interrupt = chat_manager.has_pending_interrupt(session_id)
593
+ interrupt_value = None
594
+
595
+ if has_interrupt:
596
+ interrupt_info = chat_manager.pending_interrupts.get(session_id, {})
597
+ interrupt_data_obj = interrupt_info.get("interrupt_data")
598
+
599
+ if hasattr(interrupt_data_obj, 'value'):
600
+ interrupt_value = interrupt_data_obj.value
601
+ elif isinstance(interrupt_data_obj, dict):
602
+ interrupt_value = interrupt_data_obj.get("value", {})
603
+
604
+ return ChatResponse(
605
+ response=response_text,
606
+ session_id=session_id,
607
+ has_interrupt=has_interrupt,
608
+ interrupt_type="human_approval" if has_interrupt else None,
609
+ interrupt_data=interrupt_value
610
+ )
611
+
 
 
 
 
 
 
612
  except Exception as e:
613
+ logger.exception(f"Error in chat: {e}")
614
+ raise HTTPException(status_code=500, detail=str(e))
615
 
616
+ @app.post("/chat/stream")
617
+ async def chat_stream(request: ChatRequest):
618
+ """
619
+ Streaming chat endpoint using Server-Sent Events (SSE).
620
+ Streams tokens in real-time and handles interrupts.
621
+ """
622
+ if not system_initialized or not chat_manager:
623
+ raise HTTPException(status_code=503, detail="System initializing...")
624
+
625
+ session_id = request.session_id or f"api_{uuid4()}"
 
 
 
 
 
 
 
 
 
 
 
 
 
626
 
627
  return StreamingResponse(
628
+ stream_chat_response(request.message, session_id),
629
  media_type="text/event-stream",
630
  headers={
631
  "Cache-Control": "no-cache",
632
  "Connection": "keep-alive",
633
+ "X-Accel-Buffering": "no"
634
  }
635
  )
636
 
637
+ @app.post("/sessions/{session_id}/approve")
638
+ async def approve_assistance(session_id: str, request: ApprovalRequest):
639
+ """Approve or reject assistance request"""
640
+ if not chat_manager:
641
+ raise HTTPException(status_code=503, detail="System not initialized")
642
+
643
+ if not chat_manager.has_pending_interrupt(session_id):
644
+ raise HTTPException(status_code=404, detail="No pending interrupt")
645
+
646
+ try:
647
+ decision_text = f"{request.decision} {request.reason or ''}"
648
+ response_text = await chat_manager.chat(
649
+ message=decision_text,
650
+ session_id=session_id
651
+ )
652
+
653
+ return {
654
+ "status": "success",
655
+ "decision": request.decision,
656
+ "response": response_text,
657
+ "session_id": session_id
658
+ }
659
+ except Exception as e:
660
+ logger.exception(f"Error handling approval: {e}")
661
+ raise HTTPException(status_code=500, detail=str(e))
662
+
663
+ @app.get("/sessions/{session_id}/status")
664
+ async def get_session_status(session_id: str):
665
+ """Get session status including interrupt state"""
666
+ if not chat_manager:
667
+ raise HTTPException(status_code=503, detail="System not initialized")
668
+
669
+ has_interrupt = chat_manager.has_pending_interrupt(session_id)
670
+
671
+ interrupt_data = None
672
+ if has_interrupt:
673
+ interrupt_info = chat_manager.pending_interrupts.get(session_id, {})
674
+ interrupt_data_obj = interrupt_info.get("interrupt_data")
675
+
676
+ if hasattr(interrupt_data_obj, 'value'):
677
+ interrupt_data = interrupt_data_obj.value
678
+
679
+ return {
680
+ "session_id": session_id,
681
+ "has_pending_interrupt": has_interrupt,
682
+ "interrupt_data": interrupt_data
683
+ }
684
+
685
  @app.get("/sessions/{session_id}/history")
686
+ async def get_history(session_id: str):
687
+ """Get conversation history"""
688
  if not chat_manager:
689
+ raise HTTPException(status_code=503, detail="System not initialized")
690
 
691
  try:
692
  history = await chat_manager.get_conversation_history(session_id)
 
694
  "session_id": session_id,
695
  "history": [
696
  {
697
+ "role": msg.type if hasattr(msg, 'type') else "unknown",
698
+ "content": msg.content if hasattr(msg, 'content') else str(msg)
 
699
  }
700
  for msg in history
701
  ]
702
  }
703
  except Exception as e:
704
+ raise HTTPException(status_code=500, detail=str(e))
api/models/__init__.py ADDED
File without changes
api/routes/__init__.py ADDED
File without changes
api/services/__init__.py ADDED
File without changes
api/utils/__init__.py ADDED
File without changes