new file: .dockerignore

new file:   .env.example
	new file:   Dockerfile
	new file:   app.py
	new file:   blueprints/__init__.py
	new file:   blueprints/auth.py
	new file:   blueprints/chat.py
	new file:   blueprints/context.py
	new file:   blueprints/documents.py
	new file:   blueprints/main.py
	new file:   config.py
	new file:   docker-compose.yml
	new file:   models/__init__.py
	new file:   models/chat_session.py
	new file:   models/document.py
	new file:   models/user.py
	new file:   requirements.txt
	new file:   services/__init__.py
	new file:   services/document_parser.py
	new file:   services/llm_service.py
	new file:   services/rag_service.py
	new file:   services/url_scraper.py
	new file:   static/css/style.css
	new file:   static/js/chat.js
	new file:   static/js/inline_chat.js
	new file:   static/js/main.js
	new file:   templates/base.html
	new file:   templates/document_view.html
	new file:   templates/index.html
	new file:   templates/login.html
	new file:   templates/register.html
This commit is contained in:
SimolZimol
2026-05-22 16:03:50 +02:00
commit 939cc13689
31 changed files with 2025 additions and 0 deletions

1
blueprints/__init__.py Normal file
View File

@@ -0,0 +1 @@
# blueprints package

68
blueprints/auth.py Normal file
View File

@@ -0,0 +1,68 @@
import bcrypt
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, Length, EqualTo, ValidationError
from models import db, User
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
class RegisterForm(FlaskForm):
username = StringField("Username", validators=[DataRequired(), Length(3, 64)])
email = StringField("Email", validators=[DataRequired(), Email(), Length(max=120)])
password = PasswordField("Password", validators=[DataRequired(), Length(min=8)])
confirm = PasswordField("Confirm Password", validators=[DataRequired(), EqualTo("password")])
submit = SubmitField("Register")
def validate_username(self, field):
if User.query.filter_by(username=field.data).first():
raise ValidationError("Username already taken.")
def validate_email(self, field):
if User.query.filter_by(email=field.data).first():
raise ValidationError("Email already registered.")
class LoginForm(FlaskForm):
email = StringField("Email", validators=[DataRequired(), Email()])
password = PasswordField("Password", validators=[DataRequired()])
submit = SubmitField("Login")
@auth_bp.route("/register", methods=["GET", "POST"])
def register():
if current_user.is_authenticated:
return redirect(url_for("main.index"))
form = RegisterForm()
if form.validate_on_submit():
pw_hash = bcrypt.hashpw(form.password.data.encode(), bcrypt.gensalt()).decode()
user = User(username=form.username.data, email=form.email.data, password_hash=pw_hash)
db.session.add(user)
db.session.commit()
flash("Account created. Please log in.", "success")
return redirect(url_for("auth.login"))
return render_template("register.html", form=form)
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("main.index"))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user and bcrypt.checkpw(form.password.data.encode(), user.password_hash.encode()):
login_user(user)
next_page = request.args.get("next")
return redirect(next_page or url_for("main.index"))
flash("Invalid email or password.", "danger")
return render_template("login.html", form=form)
@auth_bp.route("/logout")
@login_required
def logout():
logout_user()
return redirect(url_for("auth.login"))

154
blueprints/chat.py Normal file
View File

@@ -0,0 +1,154 @@
import json
from flask import Blueprint, request, jsonify, current_app
from flask_login import login_required, current_user
from models import db, ChatSession, ChatMessage
from services import rag_service, llm_service
chat_bp = Blueprint("chat", __name__, url_prefix="/api/chat")
# ── Session management ──────────────────────────────────────────────────────
@chat_bp.route("/sessions", methods=["GET"])
@login_required
def list_sessions():
sessions = (
ChatSession.query.filter_by(user_id=current_user.id)
.order_by(ChatSession.updated_at.desc())
.limit(50)
.all()
)
return jsonify([s.to_dict() for s in sessions])
@chat_bp.route("/sessions", methods=["POST"])
@login_required
def create_session():
data = request.get_json(silent=True) or {}
session = ChatSession(user_id=current_user.id, title=data.get("title", "New Chat"))
db.session.add(session)
db.session.commit()
return jsonify(session.to_dict()), 201
@chat_bp.route("/sessions/<int:session_id>", methods=["DELETE"])
@login_required
def delete_session(session_id):
session = ChatSession.query.filter_by(id=session_id, user_id=current_user.id).first_or_404()
db.session.delete(session)
db.session.commit()
return jsonify({"success": True})
@chat_bp.route("/sessions/<int:session_id>/messages", methods=["GET"])
@login_required
def get_messages(session_id):
session = ChatSession.query.filter_by(id=session_id, user_id=current_user.id).first_or_404()
return jsonify([m.to_dict() for m in session.messages])
# ── Main chat ────────────────────────────────────────────────────────────────
@chat_bp.route("/sessions/<int:session_id>/ask", methods=["POST"])
@login_required
def ask(session_id):
session = ChatSession.query.filter_by(id=session_id, user_id=current_user.id).first_or_404()
data = request.get_json(silent=True) or {}
message = (data.get("message") or "").strip()
if not message:
return jsonify({"error": "No message provided"}), 400
# context_ids: list of objects {"id": int, "type": "doc"|"url"}
context_refs = data.get("context_ids", [])
doc_ids = [r["id"] for r in context_refs if r.get("type") == "doc"]
url_ids = [r["id"] for r in context_refs if r.get("type") == "url"]
# RAG lookup
chunks = []
if doc_ids:
chunks += rag_service.similarity_search(
query=message,
user_id=current_user.id,
source_ids=doc_ids,
source_type="doc",
top_k=current_app.config["RAG_TOP_K"],
)
if url_ids:
chunks += rag_service.similarity_search(
query=message,
user_id=current_user.id,
source_ids=url_ids,
source_type="url",
top_k=current_app.config["RAG_TOP_K"],
)
# If no specific ids given, search all user context
if not context_refs:
chunks = rag_service.similarity_search(
query=message,
user_id=current_user.id,
top_k=current_app.config["RAG_TOP_K"],
)
# Build history (last 10 messages for context window)
history = [
{"role": m.role, "content": m.content}
for m in session.messages[-10:]
]
try:
reply = llm_service.ask(
user_message=message,
context_chunks=chunks,
history=history,
)
except Exception as e:
current_app.logger.error(f"LLM error: {e}")
return jsonify({"error": f"LLM request failed: {str(e)}"}), 502
# Persist messages
user_msg = ChatMessage(
session_id=session.id,
role="user",
content=message,
context_ids=json.dumps(context_refs),
)
assistant_msg = ChatMessage(
session_id=session.id,
role="assistant",
content=reply,
)
db.session.add_all([user_msg, assistant_msg])
# Update session title after first user message
if len(session.messages) == 0:
session.title = message[:60] + ("" if len(message) > 60 else "")
db.session.commit()
return jsonify({
"reply": reply,
"context_used": len(chunks),
"message_id": assistant_msg.id,
})
# ── Inline chat ──────────────────────────────────────────────────────────────
@chat_bp.route("/inline", methods=["POST"])
@login_required
def inline():
data = request.get_json(silent=True) or {}
selected_text = (data.get("selected_text") or "").strip()
question = (data.get("question") or "").strip()
if not selected_text or not question:
return jsonify({"error": "selected_text and question are required"}), 400
try:
reply = llm_service.ask_inline(selected_text=selected_text, question=question)
except Exception as e:
current_app.logger.error(f"Inline LLM error: {e}")
return jsonify({"error": f"LLM request failed: {str(e)}"}), 502
return jsonify({"reply": reply})

63
blueprints/context.py Normal file
View File

@@ -0,0 +1,63 @@
from flask import Blueprint, request, jsonify, current_app
from flask_login import login_required, current_user
from models import db, UrlContext
from services.url_scraper import scrape_url
from services import rag_service
context_bp = Blueprint("context", __name__, url_prefix="/api/context")
@context_bp.route("/urls", methods=["GET"])
@login_required
def list_urls():
urls = UrlContext.query.filter_by(user_id=current_user.id).order_by(UrlContext.created_at.desc()).all()
return jsonify([u.to_dict() for u in urls])
@context_bp.route("/urls", methods=["POST"])
@login_required
def add_url():
data = request.get_json(silent=True) or {}
url = (data.get("url") or "").strip()
if not url:
return jsonify({"error": "No URL provided"}), 400
if not url.startswith(("http://", "https://")):
return jsonify({"error": "Invalid URL. Must start with http:// or https://"}), 400
# Check for duplicate per user
existing = UrlContext.query.filter_by(user_id=current_user.id, url=url).first()
if existing:
return jsonify({"error": "URL already added"}), 409
url_ctx = UrlContext(user_id=current_user.id, url=url, indexed=False)
db.session.add(url_ctx)
db.session.commit()
try:
title, text = scrape_url(url)
url_ctx.title = title[:500]
rag_service.index_source(
text=text,
user_id=current_user.id,
source_id=url_ctx.id,
source_type="url",
chunk_size=current_app.config["RAG_CHUNK_SIZE"],
chunk_overlap=current_app.config["RAG_CHUNK_OVERLAP"],
)
url_ctx.indexed = True
db.session.commit()
except Exception as e:
current_app.logger.error(f"Scraping/indexing failed for url {url_ctx.id}: {e}")
return jsonify(url_ctx.to_dict()), 201
@context_bp.route("/urls/<int:url_id>", methods=["DELETE"])
@login_required
def delete_url(url_id):
url_ctx = UrlContext.query.filter_by(id=url_id, user_id=current_user.id).first_or_404()
rag_service.delete_source(current_user.id, url_ctx.id, "url")
db.session.delete(url_ctx)
db.session.commit()
return jsonify({"success": True})

100
blueprints/documents.py Normal file
View File

@@ -0,0 +1,100 @@
import os
import uuid
from flask import Blueprint, request, jsonify, current_app
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from models import db, Document
from services.document_parser import parse_document
from services import rag_service
documents_bp = Blueprint("documents", __name__, url_prefix="/api/documents")
def _allowed(filename: str) -> bool:
allowed = current_app.config["ALLOWED_EXTENSIONS"]
return "." in filename and filename.rsplit(".", 1)[1].lower() in allowed
@documents_bp.route("/", methods=["GET"])
@login_required
def list_documents():
docs = Document.query.filter_by(user_id=current_user.id).order_by(Document.created_at.desc()).all()
return jsonify([d.to_dict() for d in docs])
@documents_bp.route("/upload", methods=["POST"])
@login_required
def upload():
if "file" not in request.files:
return jsonify({"error": "No file provided"}), 400
file = request.files["file"]
if not file.filename:
return jsonify({"error": "No filename"}), 400
if not _allowed(file.filename):
return jsonify({"error": "File type not allowed"}), 400
original_name = secure_filename(file.filename)
ext = original_name.rsplit(".", 1)[1].lower()
stored_name = f"{uuid.uuid4().hex}.{ext}"
file_path = os.path.join(current_app.config["UPLOAD_FOLDER"], stored_name)
file.save(file_path)
doc = Document(
user_id=current_user.id,
filename=stored_name,
original_name=original_name,
file_type=ext,
indexed=False,
)
db.session.add(doc)
db.session.commit()
# Index in background (synchronous for simplicity — fast enough for normal docs)
try:
text = parse_document(file_path, ext)
rag_service.index_source(
text=text,
user_id=current_user.id,
source_id=doc.id,
source_type="doc",
chunk_size=current_app.config["RAG_CHUNK_SIZE"],
chunk_overlap=current_app.config["RAG_CHUNK_OVERLAP"],
)
doc.indexed = True
db.session.commit()
except Exception as e:
current_app.logger.error(f"Indexing failed for doc {doc.id}: {e}")
return jsonify(doc.to_dict()), 201
@documents_bp.route("/<int:doc_id>", methods=["DELETE"])
@login_required
def delete_document(doc_id):
doc = Document.query.filter_by(id=doc_id, user_id=current_user.id).first_or_404()
# Remove from vector store
rag_service.delete_source(current_user.id, doc.id, "doc")
# Remove file
file_path = os.path.join(current_app.config["UPLOAD_FOLDER"], doc.filename)
if os.path.exists(file_path):
os.remove(file_path)
db.session.delete(doc)
db.session.commit()
return jsonify({"success": True})
@documents_bp.route("/<int:doc_id>/content", methods=["GET"])
@login_required
def get_content(doc_id):
doc = Document.query.filter_by(id=doc_id, user_id=current_user.id).first_or_404()
file_path = os.path.join(current_app.config["UPLOAD_FOLDER"], doc.filename)
try:
text = parse_document(file_path, doc.file_type)
except Exception as e:
return jsonify({"error": str(e)}), 500
return jsonify({"id": doc.id, "name": doc.original_name, "content": text})

16
blueprints/main.py Normal file
View File

@@ -0,0 +1,16 @@
from flask import Blueprint, render_template
from flask_login import login_required
main_bp = Blueprint("main", __name__)
@main_bp.route("/")
@login_required
def index():
return render_template("index.html")
@main_bp.route("/document/<int:doc_id>")
@login_required
def document_view(doc_id):
return render_template("document_view.html", doc_id=doc_id)