burtenshaw HF Staff commited on
Commit
ab1b163
·
1 Parent(s): b569680

Upload folder using huggingface_hub

Browse files
backend/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """LLM Council backend package."""
backend/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (180 Bytes). View file
 
backend/__pycache__/config.cpython-310.pyc ADDED
Binary file (549 Bytes). View file
 
backend/__pycache__/council.cpython-310.pyc ADDED
Binary file (8.46 kB). View file
 
backend/__pycache__/debug_models.cpython-310.pyc ADDED
Binary file (883 Bytes). View file
 
backend/__pycache__/openrouter.cpython-310.pyc ADDED
Binary file (2.46 kB). View file
 
backend/config.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration for the LLM Council."""
2
+
3
+ import os
4
+ from dotenv import load_dotenv
5
+
6
+ load_dotenv()
7
+
8
+ # OpenRouter API key
9
+ OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
10
+
11
+ # Council members - list of OpenRouter model identifiers
12
+ COUNCIL_MODELS = [
13
+ "openai/gpt-5.1",
14
+ "google/gemini-3-pro-preview",
15
+ "anthropic/claude-sonnet-4.5",
16
+ "x-ai/grok-4",
17
+ ]
18
+
19
+ # Chairman model - synthesizes final response
20
+ CHAIRMAN_MODEL = "google/gemini-3-pro-preview"
21
+
22
+ # OpenRouter API endpoint
23
+ OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
24
+
25
+ # Data directory for conversation storage
26
+ DATA_DIR = "data/conversations"
backend/council.py ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """3-stage LLM Council orchestration."""
2
+
3
+ from typing import List, Dict, Any, Tuple
4
+ from .openrouter import query_models_parallel, query_model
5
+ from .config import COUNCIL_MODELS, CHAIRMAN_MODEL
6
+
7
+
8
+ async def stage1_collect_responses(user_query: str) -> List[Dict[str, Any]]:
9
+ """
10
+ Stage 1: Collect individual responses from all council models.
11
+
12
+ Args:
13
+ user_query: The user's question
14
+
15
+ Returns:
16
+ List of dicts with 'model' and 'response' keys
17
+ """
18
+ messages = [{"role": "user", "content": user_query}]
19
+
20
+ # Query all models in parallel
21
+ responses = await query_models_parallel(COUNCIL_MODELS, messages)
22
+
23
+ # Format results
24
+ stage1_results = []
25
+ for model, response in responses.items():
26
+ if response is not None: # Only include successful responses
27
+ stage1_results.append({
28
+ "model": model,
29
+ "response": response.get('content', '')
30
+ })
31
+
32
+ return stage1_results
33
+
34
+
35
+ async def stage2_collect_rankings(
36
+ user_query: str,
37
+ stage1_results: List[Dict[str, Any]]
38
+ ) -> Tuple[List[Dict[str, Any]], Dict[str, str]]:
39
+ """
40
+ Stage 2: Each model ranks the anonymized responses.
41
+
42
+ Args:
43
+ user_query: The original user query
44
+ stage1_results: Results from Stage 1
45
+
46
+ Returns:
47
+ Tuple of (rankings list, label_to_model mapping)
48
+ """
49
+ # Create anonymized labels for responses (Response A, Response B, etc.)
50
+ labels = [chr(65 + i) for i in range(len(stage1_results))] # A, B, C, ...
51
+
52
+ # Create mapping from label to model name
53
+ label_to_model = {
54
+ f"Response {label}": result['model']
55
+ for label, result in zip(labels, stage1_results)
56
+ }
57
+
58
+ # Build the ranking prompt
59
+ responses_text = "\n\n".join([
60
+ f"Response {label}:\n{result['response']}"
61
+ for label, result in zip(labels, stage1_results)
62
+ ])
63
+
64
+ ranking_prompt = f"""You are evaluating different responses to the following question:
65
+
66
+ Question: {user_query}
67
+
68
+ Here are the responses from different models (anonymized):
69
+
70
+ {responses_text}
71
+
72
+ Your task:
73
+ 1. First, evaluate each response individually. For each response, explain what it does well and what it does poorly.
74
+ 2. Then, at the very end of your response, provide a final ranking.
75
+
76
+ IMPORTANT: Your final ranking MUST be formatted EXACTLY as follows:
77
+ - Start with the line "FINAL RANKING:" (all caps, with colon)
78
+ - Then list the responses from best to worst as a numbered list
79
+ - Each line should be: number, period, space, then ONLY the response label (e.g., "1. Response A")
80
+ - Do not add any other text or explanations in the ranking section
81
+
82
+ Example of the correct format for your ENTIRE response:
83
+
84
+ Response A provides good detail on X but misses Y...
85
+ Response B is accurate but lacks depth on Z...
86
+ Response C offers the most comprehensive answer...
87
+
88
+ FINAL RANKING:
89
+ 1. Response C
90
+ 2. Response A
91
+ 3. Response B
92
+
93
+ Now provide your evaluation and ranking:"""
94
+
95
+ messages = [{"role": "user", "content": ranking_prompt}]
96
+
97
+ # Get rankings from all council models in parallel
98
+ responses = await query_models_parallel(COUNCIL_MODELS, messages)
99
+
100
+ # Format results
101
+ stage2_results = []
102
+ for model, response in responses.items():
103
+ if response is not None:
104
+ full_text = response.get('content', '')
105
+ parsed = parse_ranking_from_text(full_text)
106
+ stage2_results.append({
107
+ "model": model,
108
+ "ranking": full_text,
109
+ "parsed_ranking": parsed
110
+ })
111
+
112
+ return stage2_results, label_to_model
113
+
114
+
115
+ async def stage3_synthesize_final(
116
+ user_query: str,
117
+ stage1_results: List[Dict[str, Any]],
118
+ stage2_results: List[Dict[str, Any]]
119
+ ) -> Dict[str, Any]:
120
+ """
121
+ Stage 3: Chairman synthesizes final response.
122
+
123
+ Args:
124
+ user_query: The original user query
125
+ stage1_results: Individual model responses from Stage 1
126
+ stage2_results: Rankings from Stage 2
127
+
128
+ Returns:
129
+ Dict with 'model' and 'response' keys
130
+ """
131
+ # Build comprehensive context for chairman
132
+ stage1_text = "\n\n".join([
133
+ f"Model: {result['model']}\nResponse: {result['response']}"
134
+ for result in stage1_results
135
+ ])
136
+
137
+ stage2_text = "\n\n".join([
138
+ f"Model: {result['model']}\nRanking: {result['ranking']}"
139
+ for result in stage2_results
140
+ ])
141
+
142
+ chairman_prompt = f"""You are the Chairman of an LLM Council. Multiple AI models have provided responses to a user's question, and then ranked each other's responses.
143
+
144
+ Original Question: {user_query}
145
+
146
+ STAGE 1 - Individual Responses:
147
+ {stage1_text}
148
+
149
+ STAGE 2 - Peer Rankings:
150
+ {stage2_text}
151
+
152
+ Your task as Chairman is to synthesize all of this information into a single, comprehensive, accurate answer to the user's original question. Consider:
153
+ - The individual responses and their insights
154
+ - The peer rankings and what they reveal about response quality
155
+ - Any patterns of agreement or disagreement
156
+
157
+ Provide a clear, well-reasoned final answer that represents the council's collective wisdom:"""
158
+
159
+ messages = [{"role": "user", "content": chairman_prompt}]
160
+
161
+ # Query the chairman model
162
+ response = await query_model(CHAIRMAN_MODEL, messages)
163
+
164
+ if response is None:
165
+ # Fallback if chairman fails
166
+ return {
167
+ "model": CHAIRMAN_MODEL,
168
+ "response": "Error: Unable to generate final synthesis."
169
+ }
170
+
171
+ return {
172
+ "model": CHAIRMAN_MODEL,
173
+ "response": response.get('content', '')
174
+ }
175
+
176
+
177
+ def parse_ranking_from_text(ranking_text: str) -> List[str]:
178
+ """
179
+ Parse the FINAL RANKING section from the model's response.
180
+
181
+ Args:
182
+ ranking_text: The full text response from the model
183
+
184
+ Returns:
185
+ List of response labels in ranked order
186
+ """
187
+ import re
188
+
189
+ # Look for "FINAL RANKING:" section
190
+ if "FINAL RANKING:" in ranking_text:
191
+ # Extract everything after "FINAL RANKING:"
192
+ parts = ranking_text.split("FINAL RANKING:")
193
+ if len(parts) >= 2:
194
+ ranking_section = parts[1]
195
+ # Try to extract numbered list format (e.g., "1. Response A")
196
+ # This pattern looks for: number, period, optional space, "Response X"
197
+ numbered_matches = re.findall(r'\d+\.\s*Response [A-Z]', ranking_section)
198
+ if numbered_matches:
199
+ # Extract just the "Response X" part
200
+ return [re.search(r'Response [A-Z]', m).group() for m in numbered_matches]
201
+
202
+ # Fallback: Extract all "Response X" patterns in order
203
+ matches = re.findall(r'Response [A-Z]', ranking_section)
204
+ return matches
205
+
206
+ # Fallback: try to find any "Response X" patterns in order
207
+ matches = re.findall(r'Response [A-Z]', ranking_text)
208
+ return matches
209
+
210
+
211
+ def calculate_aggregate_rankings(
212
+ stage2_results: List[Dict[str, Any]],
213
+ label_to_model: Dict[str, str]
214
+ ) -> List[Dict[str, Any]]:
215
+ """
216
+ Calculate aggregate rankings across all models.
217
+
218
+ Args:
219
+ stage2_results: Rankings from each model
220
+ label_to_model: Mapping from anonymous labels to model names
221
+
222
+ Returns:
223
+ List of dicts with model name and average rank, sorted best to worst
224
+ """
225
+ from collections import defaultdict
226
+
227
+ # Track positions for each model
228
+ model_positions = defaultdict(list)
229
+
230
+ for ranking in stage2_results:
231
+ ranking_text = ranking['ranking']
232
+
233
+ # Parse the ranking from the structured format
234
+ parsed_ranking = parse_ranking_from_text(ranking_text)
235
+
236
+ for position, label in enumerate(parsed_ranking, start=1):
237
+ if label in label_to_model:
238
+ model_name = label_to_model[label]
239
+ model_positions[model_name].append(position)
240
+
241
+ # Calculate average position for each model
242
+ aggregate = []
243
+ for model, positions in model_positions.items():
244
+ if positions:
245
+ avg_rank = sum(positions) / len(positions)
246
+ aggregate.append({
247
+ "model": model,
248
+ "average_rank": round(avg_rank, 2),
249
+ "rankings_count": len(positions)
250
+ })
251
+
252
+ # Sort by average rank (lower is better)
253
+ aggregate.sort(key=lambda x: x['average_rank'])
254
+
255
+ return aggregate
256
+
257
+
258
+ async def generate_conversation_title(user_query: str) -> str:
259
+ """
260
+ Generate a short title for a conversation based on the first user message.
261
+
262
+ Args:
263
+ user_query: The first user message
264
+
265
+ Returns:
266
+ A short title (3-5 words)
267
+ """
268
+ title_prompt = f"""Generate a very short title (3-5 words maximum) that summarizes the following question.
269
+ The title should be concise and descriptive. Do not use quotes or punctuation in the title.
270
+
271
+ Question: {user_query}
272
+
273
+ Title:"""
274
+
275
+ messages = [{"role": "user", "content": title_prompt}]
276
+
277
+ # Use gemini-2.5-flash for title generation (fast and cheap)
278
+ response = await query_model("google/gemini-2.5-flash", messages, timeout=30.0)
279
+
280
+ if response is None:
281
+ # Fallback to a generic title
282
+ return "New Conversation"
283
+
284
+ title = response.get('content', 'New Conversation').strip()
285
+
286
+ # Clean up the title - remove quotes, limit length
287
+ title = title.strip('"\'')
288
+
289
+ # Truncate if too long
290
+ if len(title) > 50:
291
+ title = title[:47] + "..."
292
+
293
+ return title
294
+
295
+
296
+ async def run_full_council(user_query: str) -> Tuple[List, List, Dict, Dict]:
297
+ """
298
+ Run the complete 3-stage council process.
299
+
300
+ Args:
301
+ user_query: The user's question
302
+
303
+ Returns:
304
+ Tuple of (stage1_results, stage2_results, stage3_result, metadata)
305
+ """
306
+ # Stage 1: Collect individual responses
307
+ stage1_results = await stage1_collect_responses(user_query)
308
+
309
+ # If no models responded successfully, return error
310
+ if not stage1_results:
311
+ return [], [], {
312
+ "model": "error",
313
+ "response": "All models failed to respond. Please try again."
314
+ }, {}
315
+
316
+ # Stage 2: Collect rankings
317
+ stage2_results, label_to_model = await stage2_collect_rankings(user_query, stage1_results)
318
+
319
+ # Calculate aggregate rankings
320
+ aggregate_rankings = calculate_aggregate_rankings(stage2_results, label_to_model)
321
+
322
+ # Stage 3: Synthesize final answer
323
+ stage3_result = await stage3_synthesize_final(
324
+ user_query,
325
+ stage1_results,
326
+ stage2_results
327
+ )
328
+
329
+ # Prepare metadata
330
+ metadata = {
331
+ "label_to_model": label_to_model,
332
+ "aggregate_rankings": aggregate_rankings
333
+ }
334
+
335
+ return stage1_results, stage2_results, stage3_result, metadata
backend/debug_models.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import asyncio
3
+ import os
4
+ from backend.openrouter import query_model
5
+ from backend.config import COUNCIL_MODELS
6
+
7
+ async def test_models():
8
+ print("Testing OpenRouter connection...")
9
+ print(f"API Key present: {'Yes' if os.getenv('OPENROUTER_API_KEY') else 'No'}")
10
+
11
+ messages = [{"role": "user", "content": "Say hello!"}]
12
+
13
+ for model in COUNCIL_MODELS:
14
+ print(f"\nTesting model: {model}")
15
+ response = await query_model(model, messages, timeout=30.0)
16
+ if response:
17
+ print("✅ Success!")
18
+ print(f"Response: {response.get('content')[:50]}...")
19
+ else:
20
+ print("❌ Failed")
21
+
22
+ if __name__ == "__main__":
23
+ asyncio.run(test_models())
24
+
backend/main.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI backend for LLM Council."""
2
+
3
+ from fastapi import FastAPI, HTTPException
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ from fastapi.responses import StreamingResponse
6
+ from pydantic import BaseModel
7
+ from typing import List, Dict, Any
8
+ import uuid
9
+ import json
10
+ import asyncio
11
+
12
+ from . import storage
13
+ from .council import run_full_council, generate_conversation_title, stage1_collect_responses, stage2_collect_rankings, stage3_synthesize_final, calculate_aggregate_rankings
14
+
15
+ app = FastAPI(title="LLM Council API")
16
+
17
+ # Enable CORS for local development
18
+ app.add_middleware(
19
+ CORSMiddleware,
20
+ allow_origins=["http://localhost:5173", "http://localhost:3000"],
21
+ allow_credentials=True,
22
+ allow_methods=["*"],
23
+ allow_headers=["*"],
24
+ )
25
+
26
+
27
+ class CreateConversationRequest(BaseModel):
28
+ """Request to create a new conversation."""
29
+ pass
30
+
31
+
32
+ class SendMessageRequest(BaseModel):
33
+ """Request to send a message in a conversation."""
34
+ content: str
35
+
36
+
37
+ class ConversationMetadata(BaseModel):
38
+ """Conversation metadata for list view."""
39
+ id: str
40
+ created_at: str
41
+ title: str
42
+ message_count: int
43
+
44
+
45
+ class Conversation(BaseModel):
46
+ """Full conversation with all messages."""
47
+ id: str
48
+ created_at: str
49
+ title: str
50
+ messages: List[Dict[str, Any]]
51
+
52
+
53
+ @app.get("/")
54
+ async def root():
55
+ """Health check endpoint."""
56
+ return {"status": "ok", "service": "LLM Council API"}
57
+
58
+
59
+ @app.get("/api/conversations", response_model=List[ConversationMetadata])
60
+ async def list_conversations():
61
+ """List all conversations (metadata only)."""
62
+ return storage.list_conversations()
63
+
64
+
65
+ @app.post("/api/conversations", response_model=Conversation)
66
+ async def create_conversation(request: CreateConversationRequest):
67
+ """Create a new conversation."""
68
+ conversation_id = str(uuid.uuid4())
69
+ conversation = storage.create_conversation(conversation_id)
70
+ return conversation
71
+
72
+
73
+ @app.get("/api/conversations/{conversation_id}", response_model=Conversation)
74
+ async def get_conversation(conversation_id: str):
75
+ """Get a specific conversation with all its messages."""
76
+ conversation = storage.get_conversation(conversation_id)
77
+ if conversation is None:
78
+ raise HTTPException(status_code=404, detail="Conversation not found")
79
+ return conversation
80
+
81
+
82
+ @app.post("/api/conversations/{conversation_id}/message")
83
+ async def send_message(conversation_id: str, request: SendMessageRequest):
84
+ """
85
+ Send a message and run the 3-stage council process.
86
+ Returns the complete response with all stages.
87
+ """
88
+ # Check if conversation exists
89
+ conversation = storage.get_conversation(conversation_id)
90
+ if conversation is None:
91
+ raise HTTPException(status_code=404, detail="Conversation not found")
92
+
93
+ # Check if this is the first message
94
+ is_first_message = len(conversation["messages"]) == 0
95
+
96
+ # Add user message
97
+ storage.add_user_message(conversation_id, request.content)
98
+
99
+ # If this is the first message, generate a title
100
+ if is_first_message:
101
+ title = await generate_conversation_title(request.content)
102
+ storage.update_conversation_title(conversation_id, title)
103
+
104
+ # Run the 3-stage council process
105
+ stage1_results, stage2_results, stage3_result, metadata = await run_full_council(
106
+ request.content
107
+ )
108
+
109
+ # Add assistant message with all stages
110
+ storage.add_assistant_message(
111
+ conversation_id,
112
+ stage1_results,
113
+ stage2_results,
114
+ stage3_result
115
+ )
116
+
117
+ # Return the complete response with metadata
118
+ return {
119
+ "stage1": stage1_results,
120
+ "stage2": stage2_results,
121
+ "stage3": stage3_result,
122
+ "metadata": metadata
123
+ }
124
+
125
+
126
+ @app.post("/api/conversations/{conversation_id}/message/stream")
127
+ async def send_message_stream(conversation_id: str, request: SendMessageRequest):
128
+ """
129
+ Send a message and stream the 3-stage council process.
130
+ Returns Server-Sent Events as each stage completes.
131
+ """
132
+ # Check if conversation exists
133
+ conversation = storage.get_conversation(conversation_id)
134
+ if conversation is None:
135
+ raise HTTPException(status_code=404, detail="Conversation not found")
136
+
137
+ # Check if this is the first message
138
+ is_first_message = len(conversation["messages"]) == 0
139
+
140
+ async def event_generator():
141
+ try:
142
+ # Add user message
143
+ storage.add_user_message(conversation_id, request.content)
144
+
145
+ # Start title generation in parallel (don't await yet)
146
+ title_task = None
147
+ if is_first_message:
148
+ title_task = asyncio.create_task(generate_conversation_title(request.content))
149
+
150
+ # Stage 1: Collect responses
151
+ yield f"data: {json.dumps({'type': 'stage1_start'})}\n\n"
152
+ stage1_results = await stage1_collect_responses(request.content)
153
+ yield f"data: {json.dumps({'type': 'stage1_complete', 'data': stage1_results})}\n\n"
154
+
155
+ # Stage 2: Collect rankings
156
+ yield f"data: {json.dumps({'type': 'stage2_start'})}\n\n"
157
+ stage2_results, label_to_model = await stage2_collect_rankings(request.content, stage1_results)
158
+ aggregate_rankings = calculate_aggregate_rankings(stage2_results, label_to_model)
159
+ yield f"data: {json.dumps({'type': 'stage2_complete', 'data': stage2_results, 'metadata': {'label_to_model': label_to_model, 'aggregate_rankings': aggregate_rankings}})}\n\n"
160
+
161
+ # Stage 3: Synthesize final answer
162
+ yield f"data: {json.dumps({'type': 'stage3_start'})}\n\n"
163
+ stage3_result = await stage3_synthesize_final(request.content, stage1_results, stage2_results)
164
+ yield f"data: {json.dumps({'type': 'stage3_complete', 'data': stage3_result})}\n\n"
165
+
166
+ # Wait for title generation if it was started
167
+ if title_task:
168
+ title = await title_task
169
+ storage.update_conversation_title(conversation_id, title)
170
+ yield f"data: {json.dumps({'type': 'title_complete', 'data': {'title': title}})}\n\n"
171
+
172
+ # Save complete assistant message
173
+ storage.add_assistant_message(
174
+ conversation_id,
175
+ stage1_results,
176
+ stage2_results,
177
+ stage3_result
178
+ )
179
+
180
+ # Send completion event
181
+ yield f"data: {json.dumps({'type': 'complete'})}\n\n"
182
+
183
+ except Exception as e:
184
+ # Send error event
185
+ yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
186
+
187
+ return StreamingResponse(
188
+ event_generator(),
189
+ media_type="text/event-stream",
190
+ headers={
191
+ "Cache-Control": "no-cache",
192
+ "Connection": "keep-alive",
193
+ }
194
+ )
195
+
196
+
197
+ if __name__ == "__main__":
198
+ import uvicorn
199
+ uvicorn.run(app, host="0.0.0.0", port=8001)
backend/openrouter.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenRouter API client for making LLM requests."""
2
+
3
+ import httpx
4
+ from typing import List, Dict, Any, Optional
5
+ from .config import OPENROUTER_API_KEY, OPENROUTER_API_URL
6
+
7
+
8
+ async def query_model(
9
+ model: str,
10
+ messages: List[Dict[str, str]],
11
+ timeout: float = 120.0
12
+ ) -> Optional[Dict[str, Any]]:
13
+ """
14
+ Query a single model via OpenRouter API.
15
+
16
+ Args:
17
+ model: OpenRouter model identifier (e.g., "openai/gpt-4o")
18
+ messages: List of message dicts with 'role' and 'content'
19
+ timeout: Request timeout in seconds
20
+
21
+ Returns:
22
+ Response dict with 'content' and optional 'reasoning_details', or None if failed
23
+ """
24
+ headers = {
25
+ "Authorization": f"Bearer {OPENROUTER_API_KEY}",
26
+ "Content-Type": "application/json",
27
+ }
28
+
29
+ payload = {
30
+ "model": model,
31
+ "messages": messages,
32
+ }
33
+
34
+ try:
35
+ async with httpx.AsyncClient(timeout=timeout) as client:
36
+ response = await client.post(
37
+ OPENROUTER_API_URL,
38
+ headers=headers,
39
+ json=payload
40
+ )
41
+ response.raise_for_status()
42
+
43
+ data = response.json()
44
+ message = data['choices'][0]['message']
45
+
46
+ return {
47
+ 'content': message.get('content'),
48
+ 'reasoning_details': message.get('reasoning_details')
49
+ }
50
+
51
+ except Exception as e:
52
+ print(f"Error querying model {model}: {e}")
53
+ return None
54
+
55
+
56
+ async def query_models_parallel(
57
+ models: List[str],
58
+ messages: List[Dict[str, str]]
59
+ ) -> Dict[str, Optional[Dict[str, Any]]]:
60
+ """
61
+ Query multiple models in parallel.
62
+
63
+ Args:
64
+ models: List of OpenRouter model identifiers
65
+ messages: List of message dicts to send to each model
66
+
67
+ Returns:
68
+ Dict mapping model identifier to response dict (or None if failed)
69
+ """
70
+ import asyncio
71
+
72
+ # Create tasks for all models
73
+ tasks = [query_model(model, messages) for model in models]
74
+
75
+ # Wait for all to complete
76
+ responses = await asyncio.gather(*tasks)
77
+
78
+ # Map models to their responses
79
+ return {model: response for model, response in zip(models, responses)}
backend/storage.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """JSON-based storage for conversations."""
2
+
3
+ import json
4
+ import os
5
+ from datetime import datetime
6
+ from typing import List, Dict, Any, Optional
7
+ from pathlib import Path
8
+ from .config import DATA_DIR
9
+
10
+
11
+ def ensure_data_dir():
12
+ """Ensure the data directory exists."""
13
+ Path(DATA_DIR).mkdir(parents=True, exist_ok=True)
14
+
15
+
16
+ def get_conversation_path(conversation_id: str) -> str:
17
+ """Get the file path for a conversation."""
18
+ return os.path.join(DATA_DIR, f"{conversation_id}.json")
19
+
20
+
21
+ def create_conversation(conversation_id: str) -> Dict[str, Any]:
22
+ """
23
+ Create a new conversation.
24
+
25
+ Args:
26
+ conversation_id: Unique identifier for the conversation
27
+
28
+ Returns:
29
+ New conversation dict
30
+ """
31
+ ensure_data_dir()
32
+
33
+ conversation = {
34
+ "id": conversation_id,
35
+ "created_at": datetime.utcnow().isoformat(),
36
+ "title": "New Conversation",
37
+ "messages": []
38
+ }
39
+
40
+ # Save to file
41
+ path = get_conversation_path(conversation_id)
42
+ with open(path, 'w') as f:
43
+ json.dump(conversation, f, indent=2)
44
+
45
+ return conversation
46
+
47
+
48
+ def get_conversation(conversation_id: str) -> Optional[Dict[str, Any]]:
49
+ """
50
+ Load a conversation from storage.
51
+
52
+ Args:
53
+ conversation_id: Unique identifier for the conversation
54
+
55
+ Returns:
56
+ Conversation dict or None if not found
57
+ """
58
+ path = get_conversation_path(conversation_id)
59
+
60
+ if not os.path.exists(path):
61
+ return None
62
+
63
+ with open(path, 'r') as f:
64
+ return json.load(f)
65
+
66
+
67
+ def save_conversation(conversation: Dict[str, Any]):
68
+ """
69
+ Save a conversation to storage.
70
+
71
+ Args:
72
+ conversation: Conversation dict to save
73
+ """
74
+ ensure_data_dir()
75
+
76
+ path = get_conversation_path(conversation['id'])
77
+ with open(path, 'w') as f:
78
+ json.dump(conversation, f, indent=2)
79
+
80
+
81
+ def list_conversations() -> List[Dict[str, Any]]:
82
+ """
83
+ List all conversations (metadata only).
84
+
85
+ Returns:
86
+ List of conversation metadata dicts
87
+ """
88
+ ensure_data_dir()
89
+
90
+ conversations = []
91
+ for filename in os.listdir(DATA_DIR):
92
+ if filename.endswith('.json'):
93
+ path = os.path.join(DATA_DIR, filename)
94
+ with open(path, 'r') as f:
95
+ data = json.load(f)
96
+ # Return metadata only
97
+ conversations.append({
98
+ "id": data["id"],
99
+ "created_at": data["created_at"],
100
+ "title": data.get("title", "New Conversation"),
101
+ "message_count": len(data["messages"])
102
+ })
103
+
104
+ # Sort by creation time, newest first
105
+ conversations.sort(key=lambda x: x["created_at"], reverse=True)
106
+
107
+ return conversations
108
+
109
+
110
+ def add_user_message(conversation_id: str, content: str):
111
+ """
112
+ Add a user message to a conversation.
113
+
114
+ Args:
115
+ conversation_id: Conversation identifier
116
+ content: User message content
117
+ """
118
+ conversation = get_conversation(conversation_id)
119
+ if conversation is None:
120
+ raise ValueError(f"Conversation {conversation_id} not found")
121
+
122
+ conversation["messages"].append({
123
+ "role": "user",
124
+ "content": content
125
+ })
126
+
127
+ save_conversation(conversation)
128
+
129
+
130
+ def add_assistant_message(
131
+ conversation_id: str,
132
+ stage1: List[Dict[str, Any]],
133
+ stage2: List[Dict[str, Any]],
134
+ stage3: Dict[str, Any]
135
+ ):
136
+ """
137
+ Add an assistant message with all 3 stages to a conversation.
138
+
139
+ Args:
140
+ conversation_id: Conversation identifier
141
+ stage1: List of individual model responses
142
+ stage2: List of model rankings
143
+ stage3: Final synthesized response
144
+ """
145
+ conversation = get_conversation(conversation_id)
146
+ if conversation is None:
147
+ raise ValueError(f"Conversation {conversation_id} not found")
148
+
149
+ conversation["messages"].append({
150
+ "role": "assistant",
151
+ "stage1": stage1,
152
+ "stage2": stage2,
153
+ "stage3": stage3
154
+ })
155
+
156
+ save_conversation(conversation)
157
+
158
+
159
+ def update_conversation_title(conversation_id: str, title: str):
160
+ """
161
+ Update the title of a conversation.
162
+
163
+ Args:
164
+ conversation_id: Conversation identifier
165
+ title: New title for the conversation
166
+ """
167
+ conversation = get_conversation(conversation_id)
168
+ if conversation is None:
169
+ raise ValueError(f"Conversation {conversation_id} not found")
170
+
171
+ conversation["title"] = title
172
+ save_conversation(conversation)