File size: 5,502 Bytes
2bb4a0d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
"""ARIA — Flask backend for Arman Adil Mangat's portfolio chatbot.

Runs as a Docker Space on HuggingFace (port 7860).
Endpoints:
    POST /chat   {"message": str, "history": [{"role": ..., "content": ...}]}
    GET  /health {"status": "ok"}

The only secret is GROQ_API_KEY (HF Space secret / local env var).
"""
from __future__ import annotations

import os
import time
from collections import defaultdict, deque

from flask import Flask, jsonify, request
from flask_cors import CORS
from groq import Groq

from rag import TOP_K, RagIndex

# ── Config ────────────────────────────────────────────────────────────────
ALLOWED_ORIGINS = [
    "https://armanadilmangat.github.io",  # GitHub Pages (production)
    "http://localhost:5173",              # Vite dev server
    "http://127.0.0.1:5173",
]
GROQ_MODEL = "llama-3.1-8b-instant"
MAX_HISTORY_MESSAGES = 12   # last 6 user/assistant turns
MAX_MESSAGE_CHARS = 1000
RATE_LIMIT = 20             # requests …
RATE_WINDOW = 60            # … per seconds, per IP

SYSTEM_PROMPT = """\
You are ARIA (Arman's Resume Intelligence Assistant), the AI assistant on \
Arman Adil Mangat's portfolio website.

Answer questions about Arman's background, projects, skills, and goals using \
ONLY the context below. Be concise, friendly, and concrete — lead with numbers \
and specifics. Keep answers under 120 words unless the visitor asks for depth.

If asked something about Arman that is not in the context, say you don't have \
that detail and suggest emailing him at aadilmangat@gmail.com. Never invent \
facts about Arman. Never share personal details beyond the provided context — \
you do not have his phone number, family details, or home address.

You may hold light general conversation (greetings, small talk, simple general \
questions), but always steer back to Arman's work.

If asked for contact details, give: aadilmangat@gmail.com · \
github.com/ArmanAdilMangat · linkedin.com/in/armanadilmangat · \
huggingface.co/ArmanXAI

CONTEXT ABOUT ARMAN:
{context}
"""

# ── App setup ─────────────────────────────────────────────────────────────
app = Flask(__name__)
CORS(app, resources={
    r"/chat": {"origins": ALLOWED_ORIGINS},
    r"/health": {"origins": "*"},
})

print("ARIA: building RAG index (one-time at startup)…", flush=True)
INDEX = RagIndex()
print(f"ARIA: index ready — {len(INDEX.chunks)} chunks.", flush=True)

groq_client = Groq(api_key=os.environ["GROQ_API_KEY"])

# ── Simple in-memory per-IP rate limiter ─────────────────────────────────
_hits: dict[str, deque] = defaultdict(deque)


def _client_ip() -> str:
    fwd = request.headers.get("X-Forwarded-For", "")
    return (fwd.split(",")[0].strip() if fwd else request.remote_addr) or "unknown"


def _rate_limited(ip: str) -> bool:
    now = time.time()
    q = _hits[ip]
    while q and now - q[0] > RATE_WINDOW:
        q.popleft()
    if len(q) >= RATE_LIMIT:
        return True
    q.append(now)
    return False


# ── Routes ────────────────────────────────────────────────────────────────
@app.get("/health")
def health():
    return jsonify({"status": "ok"})


@app.post("/chat")
def chat():
    if _rate_limited(_client_ip()):
        return jsonify({"reply": "You're sending messages a bit fast — give it "
                                 "a few seconds and try again."}), 429

    data = request.get_json(silent=True) or {}
    message = (data.get("message") or "").strip()[:MAX_MESSAGE_CHARS]
    if not message:
        return jsonify({"error": "message is required"}), 400

    # Sanitize client-supplied history (roles whitelisted, length capped).
    history = []
    for m in (data.get("history") or [])[-MAX_HISTORY_MESSAGES:]:
        role = m.get("role")
        content = (m.get("content") or "").strip()
        if role in ("user", "assistant") and content:
            history.append({"role": role, "content": content[:MAX_MESSAGE_CHARS]})

    # RAG: retrieve top-k chunks and stuff them into the system prompt.
    chunks = INDEX.search(message, TOP_K)
    context = "\n\n---\n\n".join(
        f"[{c.source}{c.heading or 'overview'}]\n{c.text}" for c in chunks
    )

    messages = [
        {"role": "system", "content": SYSTEM_PROMPT.format(context=context)},
        *history,
        {"role": "user", "content": message},
    ]

    try:
        resp = groq_client.chat.completions.create(
            model=GROQ_MODEL,
            messages=messages,
            temperature=0.4,
            max_tokens=400,
        )
        reply = (resp.choices[0].message.content or "").strip()
    except Exception:  # noqa: BLE001 — surface a friendly error, log the rest
        app.logger.exception("Groq call failed")
        return jsonify({"reply": "Hmm, my brain (the LLM API) hiccuped. Try "
                                 "again in a moment — or email Arman directly "
                                 "at aadilmangat@gmail.com."}), 502

    return jsonify({"reply": reply})


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=7860, debug=False)