xenux4u commited on
Commit
6490a0d
Β·
verified Β·
1 Parent(s): 1a91ad1

Upload 5 files

Browse files
Files changed (5) hide show
  1. .dockerignore +5 -0
  2. .env +18 -0
  3. Dockerfile +19 -0
  4. app.py +427 -0
  5. requirements.txt +7 -0
.dockerignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ .env
2
+ __pycache__/
3
+ *.pyc
4
+ .git/
5
+ orbit.db
.env ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ─── ORBIT Environment Variables ───────────────────────────────────────────
2
+ # DO NOT commit this file to version control.
3
+
4
+ # Flask Core
5
+ FLASK_SECRET_KEY=8f3a9c2e6b1d4f7a0e5c8b3d6f9a2e5c8b1d4f7a0e5c8b3d6f9a2e5c8b1d4f7
6
+ FLASK_DEBUG=true
7
+ SERVER_NAME=127.0.0.1:5000
8
+ PREFERRED_URL_SCHEME=http
9
+
10
+ # Google OAuth 2.0
11
+ GOOGLE_CLIENT_ID=454671308528-ecc7ckec4qktmilfg8u9fc0cjj8t6jg9.apps.googleusercontent.com
12
+ GOOGLE_CLIENT_SECRET=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
13
+
14
+ # Database
15
+ DATABASE_URL=sqlite:///orbit.db
16
+
17
+ # Allow OAuth over HTTP for local development (REMOVE in production)
18
+ OAUTHLIB_INSECURE_TRANSPORT=1
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Gunakan Python versi ringan
2
+ FROM python:3.10-slim
3
+
4
+ # Buat folder kerja di dalam server
5
+ WORKDIR /app
6
+
7
+ # Copy daftar library dan install
8
+ COPY requirements.txt .
9
+ RUN pip install --no-cache-dir -r requirements.txt
10
+
11
+ # Copy seluruh file proyekmu (kecuali .env)
12
+ COPY . .
13
+
14
+ # Hugging Face wajib menggunakan port 7860
15
+ EXPOSE 7860
16
+
17
+ # Perintah untuk menjalankan Flask
18
+ ENV FLASK_APP=app.py
19
+ CMD ["flask", "run", "--host=0.0.0.0", "--port=7860"]
app.py ADDED
@@ -0,0 +1,427 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ORBIT – Flask Application (SaaS Edition)
3
+ Backend: Google OAuth Β· SQLite Β· Multi-provider LLM routing
4
+ """
5
+ import os
6
+ import json
7
+ import requests
8
+ import fitz # PyMuPDF
9
+
10
+ from functools import wraps
11
+ from datetime import datetime
12
+
13
+ from flask import (
14
+ Flask, render_template, redirect, url_for,
15
+ session, jsonify, request, abort,
16
+ )
17
+ from flask_cors import CORS
18
+ from authlib.integrations.flask_client import OAuth
19
+
20
+ from config import Config
21
+ from extensions import db
22
+ from models import User, UserSettings
23
+
24
+ # ── Allow HTTP for local OAuth (dev only) ───────────────────────────────────
25
+ os.environ.setdefault("OAUTHLIB_INSECURE_TRANSPORT", "1")
26
+
27
+ # ── App Factory ─────────────────────────────────────────────────────────────
28
+ app = Flask(__name__, template_folder="templates", static_folder="static")
29
+ app.config.from_object(Config)
30
+ CORS(app)
31
+ db.init_app(app)
32
+
33
+ # ── OAuth Setup ─────────────────────────────────────────────────────────────
34
+ oauth = OAuth(app)
35
+ google = oauth.register(
36
+ name="google",
37
+ client_id=app.config["GOOGLE_CLIENT_ID"],
38
+ client_secret=app.config["GOOGLE_CLIENT_SECRET"],
39
+ server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
40
+ client_kwargs={"scope": "openid email profile"},
41
+ )
42
+
43
+ # ── DB Init ──────────────────────────────────────────────────────────────────
44
+ with app.app_context():
45
+ db.create_all()
46
+
47
+
48
+ # ─────────────────────────────────────────────
49
+ # Auth helpers
50
+ # ─────────────────────────────────────────────
51
+ def login_required(f):
52
+ @wraps(f)
53
+ def decorated(*args, **kwargs):
54
+ if "user_id" not in session:
55
+ if request.path.startswith("/api/"):
56
+ return jsonify({"error": "Authentication required."}), 401
57
+ return redirect(url_for("login_page"))
58
+ return f(*args, **kwargs)
59
+ return decorated
60
+
61
+
62
+ def get_current_user() -> User | None:
63
+ uid = session.get("user_id")
64
+ if not uid:
65
+ return None
66
+ return db.session.get(User, uid)
67
+
68
+
69
+ def seed_user_settings(user: User):
70
+ """Create default UserSettings for a brand-new user."""
71
+ cfg = Config
72
+ settings = UserSettings(
73
+ user_id = user.id,
74
+ provider = cfg.DEFAULT_PROVIDER,
75
+ base_url = cfg.DEFAULT_BASE_URL,
76
+ api_key = "",
77
+ current_model = cfg.DEFAULT_MODEL,
78
+ models_openrouter = list(cfg.DEFAULT_MODELS_OPENROUTER),
79
+ models_nvidia = list(cfg.DEFAULT_MODELS_NVIDIA),
80
+ )
81
+ db.session.add(settings)
82
+ db.session.commit()
83
+
84
+
85
+ # ─────────────────────────────────────────────
86
+ # Auth Routes
87
+ # ─────────────────────────────────────────────
88
+ @app.route("/auth/login")
89
+ def auth_login():
90
+ redirect_uri = url_for("auth_callback", _external=True)
91
+ return google.authorize_redirect(redirect_uri)
92
+
93
+
94
+ @app.route("/auth/callback")
95
+ def auth_callback():
96
+ try:
97
+ token = google.authorize_access_token()
98
+ user_info = token.get("userinfo") or google.userinfo()
99
+ except Exception as e:
100
+ return f"<h3>OAuth Error: {e}</h3><a href='/'>Retry</a>", 400
101
+
102
+ google_id = user_info.get("sub")
103
+ email = user_info.get("email", "")
104
+ name = user_info.get("name", email)
105
+ picture = user_info.get("picture", "")
106
+
107
+ # Upsert user
108
+ user = User.query.filter_by(google_id=google_id).first()
109
+ is_new = user is None
110
+
111
+ if is_new:
112
+ user = User(google_id=google_id, email=email, name=name, picture=picture)
113
+ db.session.add(user)
114
+ db.session.flush() # get user.id before commit
115
+ else:
116
+ user.name = name
117
+ user.picture = picture
118
+ user.last_login = datetime.utcnow()
119
+
120
+ db.session.commit()
121
+
122
+ # Seed settings for first-time users
123
+ if is_new or user.settings is None:
124
+ seed_user_settings(user)
125
+
126
+ session.permanent = True
127
+ session["user_id"] = user.id
128
+ return redirect(url_for("index"))
129
+
130
+
131
+ @app.route("/auth/logout")
132
+ def auth_logout():
133
+ session.clear()
134
+ return redirect(url_for("login_page"))
135
+
136
+
137
+ # ─────────────────────────────────────────────
138
+ # Page Routes
139
+ # ���────────────────────────────────────────────
140
+ @app.route("/login")
141
+ def login_page():
142
+ if "user_id" in session:
143
+ return redirect(url_for("index"))
144
+ return render_template("login.html")
145
+
146
+
147
+ @app.route("/")
148
+ @login_required
149
+ def index():
150
+ return render_template("index.html")
151
+
152
+
153
+ # ─────────────────────────────────────────────
154
+ # API: Current User
155
+ # ─────────────────────────────────────────────
156
+ @app.route("/api/me")
157
+ @login_required
158
+ def api_me():
159
+ user = get_current_user()
160
+ if not user:
161
+ return jsonify({"error": "User not found."}), 404
162
+ return jsonify(user.to_dict())
163
+
164
+
165
+ # ─────────────────────────────────────────────
166
+ # API: User Settings
167
+ # ─────────────────────────────────────────────
168
+ @app.route("/api/settings", methods=["GET"])
169
+ @login_required
170
+ def api_settings_get():
171
+ user = get_current_user()
172
+ s = user.settings
173
+ if not s:
174
+ seed_user_settings(user)
175
+ s = user.settings
176
+ return jsonify(s.to_dict())
177
+
178
+
179
+ @app.route("/api/settings", methods=["POST"])
180
+ @login_required
181
+ def api_settings_post():
182
+ user = get_current_user()
183
+ s = user.settings
184
+ if not s:
185
+ seed_user_settings(user)
186
+ s = user.settings
187
+
188
+ data = request.get_json(force=True) or {}
189
+
190
+ if "provider" in data:
191
+ s.provider = data["provider"]
192
+ # Auto-set base_url from known providers
193
+ s.base_url = Config.PROVIDER_URLS.get(data["provider"], s.base_url)
194
+
195
+ if "base_url" in data and data["base_url"].strip():
196
+ s.base_url = data["base_url"].strip()
197
+
198
+ if "api_key" in data:
199
+ s.api_key = data["api_key"].strip()
200
+
201
+ if "current_model" in data:
202
+ s.current_model = data["current_model"].strip()
203
+
204
+ if "models_openrouter" in data and isinstance(data["models_openrouter"], list):
205
+ s.models_openrouter = [m for m in data["models_openrouter"] if m]
206
+
207
+ if "models_nvidia" in data and isinstance(data["models_nvidia"], list):
208
+ s.models_nvidia = [m for m in data["models_nvidia"] if m]
209
+
210
+ s.updated_at = datetime.utcnow()
211
+ db.session.commit()
212
+ return jsonify(s.to_dict())
213
+
214
+
215
+ # ─────────────────────────────────────────────
216
+ # API: Chat
217
+ # ─────────────────────────────────────────────
218
+ @app.route("/api/chat", methods=["POST"])
219
+ @login_required
220
+ def api_chat():
221
+ user = get_current_user()
222
+ s = user.settings
223
+ if not s:
224
+ return jsonify({"error": "Settings not configured. Please save your API key first."}), 400
225
+
226
+ data = request.get_json(force=True) or {}
227
+ prompt = (data.get("prompt") or "").strip()
228
+ messages = data.get("messages", [])
229
+ # Allow client to override model (from the dropdown)
230
+ model = (data.get("model") or s.current_model or "").strip()
231
+
232
+ if not prompt:
233
+ return jsonify({"error": "Prompt is required."}), 400
234
+ if not s.api_key:
235
+ return jsonify({"error": "No API key saved. Please open Settings and add your API key."}), 400
236
+ if not model:
237
+ return jsonify({"error": "No model selected."}), 400
238
+
239
+ SYSTEM_PROMPT = (
240
+ "You are ORBIT, an intelligent Educational Research Assistant. "
241
+ "Provide accurate, well-structured academic answers. "
242
+ "Use Markdown formatting (bold, bullet points, code blocks) where appropriate. "
243
+ "Keep responses concise unless the user requests more detail."
244
+ )
245
+
246
+ api_key = s.api_key
247
+ base_url = s.base_url
248
+ provider = s.provider
249
+
250
+ try:
251
+ # ── Google Gemini ────────────────────────────────────────────────
252
+ if provider == "Google Gemini":
253
+ gemini_url = (
254
+ f"https://generativelanguage.googleapis.com/v1beta/models/"
255
+ f"{model}:generateContent?key={api_key}"
256
+ )
257
+ contents = []
258
+ for m in messages[-10:]:
259
+ role = "user" if m.get("role") == "user" else "model"
260
+ contents.append({"role": role, "parts": [{"text": m.get("content", "")}]})
261
+ if not contents or contents[-1]["role"] != "user":
262
+ contents.append({"role": "user", "parts": [{"text": f"{SYSTEM_PROMPT}\n\n{prompt}"}]})
263
+
264
+ resp = requests.post(gemini_url, json={"contents": contents}, timeout=60)
265
+ if resp.status_code != 200:
266
+ return jsonify({"error": f"Gemini error [{resp.status_code}]: {resp.text}"}), resp.status_code
267
+
268
+ result = resp.json()
269
+ try:
270
+ reply = result["candidates"][0]["content"]["parts"][0]["text"].strip()
271
+ except (KeyError, IndexError):
272
+ reply = json.dumps(result)
273
+ return jsonify({"reply": reply})
274
+
275
+ # ── OpenAI-Compatible ────────────────────────────────────────────
276
+ headers = {
277
+ "Content-Type": "application/json",
278
+ "Authorization": f"Bearer {api_key}",
279
+ "HTTP-Referer": "https://orbit-assistant.app",
280
+ "X-Title": "ORBIT Educational Assistant",
281
+ }
282
+ composed = [{"role": "system", "content": SYSTEM_PROMPT}]
283
+ for m in messages[-10:]:
284
+ if m.get("role") in ("user", "assistant"):
285
+ composed.append({"role": m["role"], "content": m.get("content", "")})
286
+ if not composed or composed[-1].get("content") != prompt:
287
+ composed.append({"role": "user", "content": prompt})
288
+
289
+ payload = {"model": model, "messages": composed, "temperature": 0.6, "max_tokens": 1536}
290
+ resp = requests.post(base_url, headers=headers, json=payload, timeout=60)
291
+
292
+ if resp.status_code != 200:
293
+ try:
294
+ err = json.dumps(resp.json())
295
+ except Exception:
296
+ err = resp.text
297
+ return jsonify({"error": f"API error [{resp.status_code}]: {err}"}), resp.status_code
298
+
299
+ reply = resp.json()["choices"][0]["message"]["content"].strip()
300
+ return jsonify({"reply": reply})
301
+
302
+ except requests.exceptions.Timeout:
303
+ return jsonify({"error": "Request timed out. The model is taking too long."}), 504
304
+ except requests.exceptions.ConnectionError:
305
+ return jsonify({"error": "Could not connect to the AI provider."}), 503
306
+ except Exception as e:
307
+ return jsonify({"error": f"Server error: {str(e)}"}), 500
308
+
309
+
310
+ # ─────────────────────────────────────────────
311
+ # API: Upload PDF
312
+ # ─────────────────────────────────────────────
313
+ @app.route("/api/upload_pdf", methods=["POST"])
314
+ @login_required
315
+ def api_upload_pdf():
316
+ if "file" not in request.files:
317
+ return jsonify({"error": "No file field in request."}), 400
318
+
319
+ f = request.files["file"]
320
+ if not f or not f.filename:
321
+ return jsonify({"error": "No file selected."}), 400
322
+ if not f.filename.lower().endswith(".pdf"):
323
+ return jsonify({"error": "Only PDF files are supported."}), 415
324
+
325
+ try:
326
+ pdf_bytes = f.read()
327
+ doc = fitz.open(stream=pdf_bytes, filetype="pdf")
328
+
329
+ parts, word_count, MAX = [], 0, 3000
330
+ for page in doc:
331
+ text = page.get_text().strip()
332
+ if not text:
333
+ continue
334
+ words = text.split()
335
+ remaining = MAX - word_count
336
+ if remaining <= 0:
337
+ break
338
+ if len(words) > remaining:
339
+ parts.append(" ".join(words[:remaining]))
340
+ word_count += remaining
341
+ break
342
+ parts.append(text)
343
+ word_count += len(words)
344
+ doc.close()
345
+
346
+ full_text = "\n\n".join(parts).strip()
347
+ if not full_text:
348
+ return jsonify({"error": "Could not extract readable text. PDF may be image-based."}), 422
349
+
350
+ return jsonify({"text": full_text, "filename": f.filename, "word_count": word_count})
351
+
352
+ except fitz.FileDataError:
353
+ return jsonify({"error": "Not a valid PDF file."}), 422
354
+ except Exception as e:
355
+ return jsonify({"error": f"PDF error: {str(e)}"}), 500
356
+
357
+
358
+ # ─────────────────────────────────────────────
359
+ # API: Validate DOI
360
+ # ─────────────────────────────────────────────
361
+ @app.route("/api/validate_doi", methods=["POST"])
362
+ @login_required
363
+ def api_validate_doi():
364
+ data = request.get_json(force=True) or {}
365
+ raw_doi = (data.get("doi") or "").strip()
366
+ if not raw_doi:
367
+ return jsonify({"error": "DOI is required."}), 400
368
+
369
+ for prefix in ["https://doi.org/", "http://doi.org/", "doi.org/"]:
370
+ if raw_doi.startswith(prefix):
371
+ raw_doi = raw_doi[len(prefix):]
372
+ break
373
+
374
+ try:
375
+ resp = requests.get(
376
+ f"https://api.crossref.org/works/{raw_doi}",
377
+ headers={"User-Agent": "ORBIT/2.0 (mailto:orbit@example.com)"},
378
+ timeout=15,
379
+ )
380
+ if resp.status_code == 404:
381
+ return jsonify({"error": f"DOI '{raw_doi}' not found in CrossRef."}), 404
382
+ if resp.status_code != 200:
383
+ return jsonify({"error": f"CrossRef error [{resp.status_code}]."}), resp.status_code
384
+
385
+ work = resp.json().get("message", {})
386
+
387
+ titles = work.get("title", [])
388
+ title = titles[0] if titles else "No title available"
389
+
390
+ authors_raw = work.get("author", [])
391
+ if authors_raw:
392
+ names = [f"{a.get('given','')} {a.get('family','')}".strip() for a in authors_raw[:5]]
393
+ authors = ", ".join(names)
394
+ if len(authors_raw) > 5:
395
+ authors += f" et al. (+{len(authors_raw)-5} more)"
396
+ else:
397
+ authors = "Not available"
398
+
399
+ year = "N/A"
400
+ for field in ["published-print", "published-online", "issued", "created"]:
401
+ parts = work.get(field, {}).get("date-parts", [[]])
402
+ if parts and parts[0]:
403
+ year = str(parts[0][0])
404
+ break
405
+
406
+ containers = work.get("container-title", [])
407
+ journal = containers[0] if containers else work.get("publisher", "N/A")
408
+ pub_type = work.get("type", "journal-article").replace("-", " ").title()
409
+
410
+ return jsonify({"doi": raw_doi, "title": title, "authors": authors,
411
+ "year": year, "journal": journal, "type": pub_type})
412
+
413
+ except requests.exceptions.Timeout:
414
+ return jsonify({"error": "CrossRef timed out."}), 504
415
+ except requests.exceptions.ConnectionError:
416
+ return jsonify({"error": "Could not connect to CrossRef."}), 503
417
+ except Exception as e:
418
+ return jsonify({"error": f"DOI error: {str(e)}"}), 500
419
+
420
+
421
+ # ─────────────────────────────────────────────
422
+ # Entry Point
423
+ # ─────────────────────────────────────────────
424
+ if __name__ == "__main__":
425
+ port = int(os.environ.get("PORT", 5000))
426
+ print(f"[ORBIT] http://127.0.0.1:{port}")
427
+ app.run(host="0.0.0.0", port=port, debug=Config.DEBUG)
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ flask>=3.0.0
2
+ flask-cors>=4.0.0
3
+ flask-sqlalchemy>=3.1.0
4
+ authlib>=1.3.0
5
+ python-dotenv>=1.0.0
6
+ requests>=2.31.0
7
+ PyMuPDF>=1.23.0