commit 939cc136890341fe77a0b7b763469eac22d09fea Author: SimolZimol <70102430+SimolZimol@users.noreply.github.com> Date: Fri May 22 16:03:50 2026 +0200 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ce42856 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.env +uploads/ +vectordb/ +.cache/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ +*.db +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..58a7383 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Copy this file to .env and fill in your values + +# ── Flask ────────────────────────────────────────────────────────────────────── +SECRET_KEY=change-me-to-a-random-secret-string +FLASK_ENV=production + +# ── Database (SQLite default — no change needed for single-instance) ─────────── +# DATABASE_URI=sqlite:////app/app.db + +# ── AI Provider ──────────────────────────────────────────────────────────────── +# "lmstudio" → uses LM_STUDIO_URL below (default) +# "openai" → uses OPENAI_API_KEY below +AI_PROVIDER=lmstudio + +# LM Studio (running externally, e.g. on the host machine) +# On Linux/Docker: use host.docker.internal to reach the host +LM_STUDIO_URL=http://host.docker.internal:1234 +LM_STUDIO_MODEL=local-model + +# OpenAI (only needed when AI_PROVIDER=openai) +# OPENAI_API_KEY=sk-... +# OPENAI_MODEL=gpt-4o + +# ── RAG tuning (optional) ────────────────────────────────────────────────────── +RAG_TOP_K=5 +RAG_CHUNK_SIZE=500 +RAG_CHUNK_OVERLAP=50 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..25b81b1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# ── Build stage ──────────────────────────────────────────────────────────────── +FROM python:3.10-slim AS builder + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir --prefix=/install -r requirements.txt + +# ── Runtime stage ────────────────────────────────────────────────────────────── +FROM python:3.10-slim + +# Non-root user for security +RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser + +WORKDIR /app + +# Copy installed packages from builder +COPY --from=builder /install /usr/local + +# Copy application code +COPY --chown=appuser:appgroup . . + +# Directories that will be mounted as volumes +RUN mkdir -p uploads vectordb .cache && chown -R appuser:appgroup uploads vectordb .cache + +USER appuser + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + FLASK_ENV=production \ + TRANSFORMERS_CACHE=/app/.cache \ + HF_HOME=/app/.cache + +EXPOSE 5000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/auth/login')" || exit 1 + +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "120", "app:create_app()"] + \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..a4ac569 --- /dev/null +++ b/app.py @@ -0,0 +1,56 @@ +import os +from flask import Flask +from flask_login import LoginManager +from config import Config +from models import db, User + + +login_manager = LoginManager() + + +def create_app(config_class=Config): + app = Flask(__name__) + app.config.from_object(config_class) + + # Ensure required directories exist + os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True) + os.makedirs(app.config["VECTORDB_PATH"], exist_ok=True) + os.makedirs(app.config["TRANSFORMERS_CACHE"], exist_ok=True) + + # Set HuggingFace cache env so sentence-transformers respects it + os.environ["TRANSFORMERS_CACHE"] = app.config["TRANSFORMERS_CACHE"] + os.environ["HF_HOME"] = app.config["TRANSFORMERS_CACHE"] + + # Extensions + db.init_app(app) + login_manager.init_app(app) + login_manager.login_view = "auth.login" + login_manager.login_message_category = "info" + + @login_manager.user_loader + def load_user(user_id): + return User.query.get(int(user_id)) + + # Blueprints + from blueprints.auth import auth_bp + from blueprints.documents import documents_bp + from blueprints.context import context_bp + from blueprints.chat import chat_bp + from blueprints.main import main_bp + + app.register_blueprint(auth_bp) + app.register_blueprint(documents_bp) + app.register_blueprint(context_bp) + app.register_blueprint(chat_bp) + app.register_blueprint(main_bp) + + # Create DB tables + with app.app_context(): + db.create_all() + + return app + + +if __name__ == "__main__": + app = create_app() + app.run(host="0.0.0.0", port=5000) diff --git a/blueprints/__init__.py b/blueprints/__init__.py new file mode 100644 index 0000000..4a9c859 --- /dev/null +++ b/blueprints/__init__.py @@ -0,0 +1 @@ +# blueprints package diff --git a/blueprints/auth.py b/blueprints/auth.py new file mode 100644 index 0000000..41f1745 --- /dev/null +++ b/blueprints/auth.py @@ -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")) diff --git a/blueprints/chat.py b/blueprints/chat.py new file mode 100644 index 0000000..1ed2f0c --- /dev/null +++ b/blueprints/chat.py @@ -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/", 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//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//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}) diff --git a/blueprints/context.py b/blueprints/context.py new file mode 100644 index 0000000..12ae7c9 --- /dev/null +++ b/blueprints/context.py @@ -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/", 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}) diff --git a/blueprints/documents.py b/blueprints/documents.py new file mode 100644 index 0000000..b44f9b8 --- /dev/null +++ b/blueprints/documents.py @@ -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("/", 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("//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}) diff --git a/blueprints/main.py b/blueprints/main.py new file mode 100644 index 0000000..dd431f5 --- /dev/null +++ b/blueprints/main.py @@ -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/") +@login_required +def document_view(doc_id): + return render_template("document_view.html", doc_id=doc_id) diff --git a/config.py b/config.py new file mode 100644 index 0000000..bab966c --- /dev/null +++ b/config.py @@ -0,0 +1,34 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +BASE_DIR = os.path.abspath(os.path.dirname(__file__)) + + +class Config: + SECRET_KEY = os.environ.get("SECRET_KEY", "change-me-in-production") + SQLALCHEMY_DATABASE_URI = os.environ.get( + "DATABASE_URI", f"sqlite:///{os.path.join(BASE_DIR, 'app.db')}" + ) + SQLALCHEMY_TRACK_MODIFICATIONS = False + + UPLOAD_FOLDER = os.environ.get("UPLOAD_FOLDER", os.path.join(BASE_DIR, "uploads")) + VECTORDB_PATH = os.environ.get("VECTORDB_PATH", os.path.join(BASE_DIR, "vectordb")) + TRANSFORMERS_CACHE = os.environ.get( + "TRANSFORMERS_CACHE", os.path.join(BASE_DIR, ".cache") + ) + + ALLOWED_EXTENSIONS = {"pdf", "txt", "docx", "md"} + MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50 MB + + # LLM Provider: "lmstudio" or "openai" + AI_PROVIDER = os.environ.get("AI_PROVIDER", "lmstudio") + LM_STUDIO_URL = os.environ.get("LM_STUDIO_URL", "http://localhost:1234") + LM_STUDIO_MODEL = os.environ.get("LM_STUDIO_MODEL", "local-model") + OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "") + OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o") + + RAG_TOP_K = int(os.environ.get("RAG_TOP_K", "5")) + RAG_CHUNK_SIZE = int(os.environ.get("RAG_CHUNK_SIZE", "500")) + RAG_CHUNK_OVERLAP = int(os.environ.get("RAG_CHUNK_OVERLAP", "50")) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ee19252 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: "3.9" + +services: + web: + build: . + restart: unless-stopped + ports: + - "5000:5000" + env_file: + - .env + volumes: + - uploads:/app/uploads + - vectordb:/app/vectordb + - hf_cache:/app/.cache + healthcheck: + test: ["CMD", "python", "-c", + "import urllib.request; urllib.request.urlopen('http://localhost:5000/auth/login')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + +volumes: + uploads: + vectordb: + hf_cache: diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..b35ba44 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,9 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() + +from .user import User +from .document import Document, UrlContext +from .chat_session import ChatSession, ChatMessage + +__all__ = ["db", "User", "Document", "UrlContext", "ChatSession", "ChatMessage"] diff --git a/models/chat_session.py b/models/chat_session.py new file mode 100644 index 0000000..ed62e1c --- /dev/null +++ b/models/chat_session.py @@ -0,0 +1,45 @@ +from datetime import datetime +import json +from . import db + + +class ChatSession(db.Model): + __tablename__ = "chat_sessions" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + title = db.Column(db.String(255), default="New Chat") + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + messages = db.relationship( + "ChatMessage", backref="session", lazy=True, cascade="all, delete-orphan", + order_by="ChatMessage.created_at" + ) + + def to_dict(self): + return { + "id": self.id, + "title": self.title, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + } + + +class ChatMessage(db.Model): + __tablename__ = "chat_messages" + + id = db.Column(db.Integer, primary_key=True) + session_id = db.Column(db.Integer, db.ForeignKey("chat_sessions.id"), nullable=False) + role = db.Column(db.String(16), nullable=False) # "user" | "assistant" + content = db.Column(db.Text, nullable=False) + context_ids = db.Column(db.Text, nullable=True) # JSON list of doc/url ids used + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def to_dict(self): + return { + "id": self.id, + "role": self.role, + "content": self.content, + "created_at": self.created_at.isoformat(), + } diff --git a/models/document.py b/models/document.py new file mode 100644 index 0000000..c81afd7 --- /dev/null +++ b/models/document.py @@ -0,0 +1,43 @@ +from datetime import datetime +from . import db + + +class Document(db.Model): + __tablename__ = "documents" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + filename = db.Column(db.String(255), nullable=False) + original_name = db.Column(db.String(255), nullable=False) + file_type = db.Column(db.String(16), nullable=False) + indexed = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def to_dict(self): + return { + "id": self.id, + "original_name": self.original_name, + "file_type": self.file_type, + "indexed": self.indexed, + "created_at": self.created_at.isoformat(), + } + + +class UrlContext(db.Model): + __tablename__ = "url_contexts" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + url = db.Column(db.String(2048), nullable=False) + title = db.Column(db.String(512), nullable=True) + indexed = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def to_dict(self): + return { + "id": self.id, + "url": self.url, + "title": self.title or self.url, + "indexed": self.indexed, + "created_at": self.created_at.isoformat(), + } diff --git a/models/user.py b/models/user.py new file mode 100644 index 0000000..a346fa8 --- /dev/null +++ b/models/user.py @@ -0,0 +1,21 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_login import UserMixin +from datetime import datetime +from . import db + + +class User(UserMixin, db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(64), unique=True, nullable=False) + email = db.Column(db.String(120), unique=True, nullable=False) + password_hash = db.Column(db.String(256), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + documents = db.relationship("Document", backref="owner", lazy=True, cascade="all, delete-orphan") + url_contexts = db.relationship("UrlContext", backref="owner", lazy=True, cascade="all, delete-orphan") + chat_sessions = db.relationship("ChatSession", backref="owner", lazy=True, cascade="all, delete-orphan") + + def __repr__(self): + return f"" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..837a6d0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,17 @@ +flask==3.0.3 +flask-login==0.6.3 +flask-sqlalchemy==3.1.1 +flask-wtf==1.2.1 +wtforms==3.1.2 +bcrypt==4.1.3 +pdfminer.six==20231228 +python-docx==1.1.2 +markdown==3.6 +beautifulsoup4==4.12.3 +requests==2.32.3 +sentence-transformers==3.0.1 +chromadb==0.5.3 +openai==1.35.3 +gunicorn==22.0.0 +python-dotenv==1.0.1 +sqlalchemy==2.0.30 diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..0274469 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1 @@ +# services package diff --git a/services/document_parser.py b/services/document_parser.py new file mode 100644 index 0000000..366b0b4 --- /dev/null +++ b/services/document_parser.py @@ -0,0 +1,32 @@ +import os +import io + + +def parse_document(file_path: str, file_type: str) -> str: + """Extract plain text from a document file.""" + ext = file_type.lower().lstrip(".") + + if ext == "txt" or ext == "md": + with open(file_path, "r", encoding="utf-8", errors="replace") as f: + return f.read() + + if ext == "pdf": + return _parse_pdf(file_path) + + if ext == "docx": + return _parse_docx(file_path) + + raise ValueError(f"Unsupported file type: {ext}") + + +def _parse_pdf(file_path: str) -> str: + from pdfminer.high_level import extract_text + text = extract_text(file_path) + return text or "" + + +def _parse_docx(file_path: str) -> str: + from docx import Document + doc = Document(file_path) + paragraphs = [p.text for p in doc.paragraphs if p.text.strip()] + return "\n".join(paragraphs) diff --git a/services/llm_service.py b/services/llm_service.py new file mode 100644 index 0000000..e5ab453 --- /dev/null +++ b/services/llm_service.py @@ -0,0 +1,109 @@ +""" +Abstracted LLM service. +Supports AI_PROVIDER=lmstudio (default) or AI_PROVIDER=openai. +Both use the openai Python client — only base_url / api_key differ. +""" + +from typing import Optional +from flask import current_app + +_client = None + + +def _get_client(): + global _client + if _client is not None: + return _client + + import openai + + provider = current_app.config.get("AI_PROVIDER", "lmstudio").lower() + + if provider == "openai": + api_key = current_app.config.get("OPENAI_API_KEY", "") + _client = openai.OpenAI(api_key=api_key) + else: + # LM Studio or any OpenAI-compatible endpoint + base_url = current_app.config.get("LM_STUDIO_URL", "http://localhost:1234") + _client = openai.OpenAI( + base_url=f"{base_url.rstrip('/')}/v1", + api_key="lm-studio", + ) + + return _client + + +def _get_model() -> str: + provider = current_app.config.get("AI_PROVIDER", "lmstudio").lower() + if provider == "openai": + return current_app.config.get("OPENAI_MODEL", "gpt-4o") + return current_app.config.get("LM_STUDIO_MODEL", "local-model") + + +def ask( + user_message: str, + context_chunks: Optional[list[str]] = None, + history: Optional[list[dict]] = None, + system_extra: Optional[str] = None, +) -> str: + """ + Send a message to the LLM with optional RAG context and chat history. + Returns the assistant reply as a string. + """ + client = _get_client() + model = _get_model() + + system_parts = [ + "You are a helpful AI assistant. Answer questions accurately based on the provided context.", + "If the context does not contain enough information, say so clearly.", + ] + + if context_chunks: + context_text = "\n\n---\n\n".join(context_chunks) + system_parts.append(f"\n\n## Context\n\n{context_text}") + + if system_extra: + system_parts.append(system_extra) + + messages = [{"role": "system", "content": "\n".join(system_parts)}] + + if history: + messages.extend(history) + + messages.append({"role": "user", "content": user_message}) + + response = client.chat.completions.create( + model=model, + messages=messages, + temperature=0.7, + ) + + return response.choices[0].message.content.strip() + + +def ask_inline(selected_text: str, question: str) -> str: + """ + Inline chat: use selected_text directly as context — no RAG lookup. + """ + system = ( + "You are a helpful AI assistant. The user has selected the following text " + "and has a question about it. Answer specifically about the selected content." + ) + messages = [ + {"role": "system", "content": system}, + { + "role": "user", + "content": f"## Selected text\n\n{selected_text}\n\n## Question\n\n{question}", + }, + ] + + client = _get_client() + model = _get_model() + + response = client.chat.completions.create( + model=model, + messages=messages, + temperature=0.7, + ) + + return response.choices[0].message.content.strip() diff --git a/services/rag_service.py b/services/rag_service.py new file mode 100644 index 0000000..524f4a1 --- /dev/null +++ b/services/rag_service.py @@ -0,0 +1,135 @@ +""" +RAG service using ChromaDB + sentence-transformers. +Each chunk is stored with metadata: user_id, source_id, source_type (doc|url). +""" + +import os +import re +from typing import Optional +from flask import current_app + +_chroma_client = None +_collection = None +_embedder = None + + +def _get_embedder(): + global _embedder + if _embedder is None: + from sentence_transformers import SentenceTransformer + cache = current_app.config.get("TRANSFORMERS_CACHE", ".cache") + _embedder = SentenceTransformer("all-MiniLM-L6-v2", cache_folder=cache) + return _embedder + + +def _get_collection(): + global _chroma_client, _collection + if _collection is None: + import chromadb + path = current_app.config["VECTORDB_PATH"] + _chroma_client = chromadb.PersistentClient(path=path) + _collection = _chroma_client.get_or_create_collection( + name="ki_context", + metadata={"hnsw:space": "cosine"}, + ) + return _collection + + +def chunk_text(text: str, chunk_size: int, overlap: int) -> list[str]: + """Split text into overlapping word-based chunks.""" + words = text.split() + chunks = [] + start = 0 + while start < len(words): + end = start + chunk_size + chunks.append(" ".join(words[start:end])) + start += chunk_size - overlap + return [c for c in chunks if c.strip()] + + +def index_source( + text: str, + user_id: int, + source_id: int, + source_type: str, # "doc" | "url" + chunk_size: int = 500, + chunk_overlap: int = 50, +): + """Chunk, embed and store text in ChromaDB. Replaces existing chunks for this source.""" + collection = _get_collection() + embedder = _get_embedder() + + # Remove old chunks for this source first + delete_source(user_id, source_id, source_type) + + chunks = chunk_text(text, chunk_size, chunk_overlap) + if not chunks: + return + + embeddings = embedder.encode(chunks, show_progress_bar=False).tolist() + + ids = [f"{source_type}_{source_id}_chunk_{i}" for i in range(len(chunks))] + metadatas = [ + {"user_id": str(user_id), "source_id": str(source_id), "source_type": source_type} + for _ in chunks + ] + + collection.add(documents=chunks, embeddings=embeddings, ids=ids, metadatas=metadatas) + + +def delete_source(user_id: int, source_id: int, source_type: str): + """Remove all chunks belonging to a source from ChromaDB.""" + collection = _get_collection() + try: + collection.delete( + where={ + "$and": [ + {"user_id": {"$eq": str(user_id)}}, + {"source_id": {"$eq": str(source_id)}}, + {"source_type": {"$eq": source_type}}, + ] + } + ) + except Exception: + pass + + +def similarity_search( + query: str, + user_id: int, + source_ids: Optional[list[int]] = None, + source_type: Optional[str] = None, + top_k: int = 5, +) -> list[str]: + """ + Search for relevant chunks. + Optionally filter by specific source_ids and/or source_type. + Returns list of chunk texts. + """ + collection = _get_collection() + embedder = _get_embedder() + + query_embedding = embedder.encode([query], show_progress_bar=False).tolist()[0] + + # Build where filter + conditions = [{"user_id": {"$eq": str(user_id)}}] + + if source_ids is not None and len(source_ids) > 0: + conditions.append( + {"source_id": {"$in": [str(sid) for sid in source_ids]}} + ) + + if source_type: + conditions.append({"source_type": {"$eq": source_type}}) + + where = {"$and": conditions} if len(conditions) > 1 else conditions[0] + + try: + results = collection.query( + query_embeddings=[query_embedding], + n_results=top_k, + where=where, + ) + return results["documents"][0] if results["documents"] else [] + except Exception: + return [] diff --git a/services/url_scraper.py b/services/url_scraper.py new file mode 100644 index 0000000..5fd78da --- /dev/null +++ b/services/url_scraper.py @@ -0,0 +1,31 @@ +import requests +from bs4 import BeautifulSoup + + +def scrape_url(url: str, timeout: int = 15) -> tuple[str, str]: + """ + Fetch a URL and return (title, plain_text). + Raises requests.RequestException on network errors. + """ + headers = { + "User-Agent": ( + "Mozilla/5.0 (compatible; KIContextTool/1.0; " + "+https://github.com/user/ki-context-tool)" + ) + } + resp = requests.get(url, headers=headers, timeout=timeout) + resp.raise_for_status() + + soup = BeautifulSoup(resp.text, "html.parser") + + # Remove script/style noise + for tag in soup(["script", "style", "nav", "footer", "header", "aside"]): + tag.decompose() + + title = soup.title.string.strip() if soup.title and soup.title.string else url + + # Extract readable text + text = soup.get_text(separator="\n", strip=True) + # Collapse excessive blank lines + lines = [line for line in text.splitlines() if line.strip()] + return title, "\n".join(lines) diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..9face69 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,69 @@ +/* Custom overrides on top of Tailwind — Copilot-style dark theme */ + +:root { + --scrollbar-thumb: #313244; + --scrollbar-track: #1e1e2e; +} + +/* Scrollbars */ +* { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +} +*::-webkit-scrollbar { width: 6px; height: 6px; } +*::-webkit-scrollbar-track { background: var(--scrollbar-track); } +*::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; } +*::-webkit-scrollbar-thumb:hover { background: #45475a; } + +/* Smooth selection highlight */ +::selection { background: rgba(137, 180, 250, 0.3); } + +/* Textarea — no resize handle */ +textarea { resize: none; } + +/* Pre block in document view */ +#doc-content { + user-select: text; + cursor: text; +} + +/* Inline popup fade-in */ +#inline-popup { + animation: fadeIn 120ms ease; +} +@keyframes fadeIn { + from { opacity: 0; transform: scale(0.97) translateY(-4px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +/* Chat bubble animations */ +#messages > div { + animation: slideIn 150ms ease; +} +@keyframes slideIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Fix Tailwind's accent color for checkboxes */ +input[type="checkbox"] { + cursor: pointer; + width: 14px; + height: 14px; +} + +/* Sidebar resize handle (visual only) */ +#sidebar { + transition: width 200ms ease; +} + +/* Flash messages */ +.flash-msg { + animation: fadeIn 200ms ease, fadeOut 300ms 3700ms ease forwards; +} +@keyframes fadeOut { + to { opacity: 0; transform: translateY(-8px); } +} + +/* Remove default focus outline on editor mount */ +#editor-mount:focus { outline: none; } diff --git a/static/js/chat.js b/static/js/chat.js new file mode 100644 index 0000000..aa17513 --- /dev/null +++ b/static/js/chat.js @@ -0,0 +1,115 @@ +// ── Chat UI controller ──────────────────────────────────────────────────────── + +const $ = (sel) => document.querySelector(sel); + +export class Chat { + constructor() { + this._container = $('#messages'); + this._emptyState = $('#empty-state'); + } + + clear() { + this._container.innerHTML = ''; + this._emptyState?.classList.remove('hidden'); + } + + renderHistory(messages) { + this._container.innerHTML = ''; + this._emptyState?.classList.add('hidden'); + messages.forEach((m) => this._appendBubble(m.role, m.content)); + this._scrollBottom(); + } + + async send(sessionId, message, contextIds = []) { + // Immediately render user bubble + this._appendBubble('user', message); + this._emptyState?.classList.add('hidden'); + + // Typing indicator + const typingId = this._appendTyping(); + + const res = await fetch(`/api/chat/sessions/${sessionId}/ask`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message, context_ids: contextIds }), + }); + + this._removeTyping(typingId); + + const data = await res.json(); + + if (!res.ok) { + this._appendBubble('assistant', `Error: ${data.error || 'Unknown error'}`, true); + } else { + this._appendBubble('assistant', data.reply); + } + + this._scrollBottom(); + } + + _appendBubble(role, content, isError = false) { + const wrap = document.createElement('div'); + wrap.className = `flex ${role === 'user' ? 'justify-end' : 'justify-start'} gap-3`; + + if (role === 'assistant') { + wrap.innerHTML = ` +
+ + + +
+
${escapeHtml(content)}
`; + } else { + wrap.innerHTML = ` +
+ ${escapeHtml(content)} +
+
+ U +
`; + } + + this._container.appendChild(wrap); + return wrap; + } + + _appendTyping() { + const id = `typing-${Date.now()}`; + const div = document.createElement('div'); + div.id = id; + div.className = 'flex justify-start gap-3'; + div.innerHTML = ` +
+ + + +
+
+ + + +
`; + this._container.appendChild(div); + this._scrollBottom(); + return id; + } + + _removeTyping(id) { + document.getElementById(id)?.remove(); + } + + _scrollBottom() { + this._container.scrollTop = this._container.scrollHeight; + } +} + +function escapeHtml(str = '') { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/static/js/inline_chat.js b/static/js/inline_chat.js new file mode 100644 index 0000000..34dbb5f --- /dev/null +++ b/static/js/inline_chat.js @@ -0,0 +1,126 @@ +// ── Inline chat for document_view.html ─────────────────────────────────────── +// Uses a plain +
+ + +
+ + + + + + +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..7549aa7 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,123 @@ +{% extends "base.html" %} +{% block title %}KI Context Tool{% endblock %} + +{% block content %} +
+ + + + + +
+ + +
+ Select or start a chat + +
+ + +
+ + +
+ + + +

Upload documents or add URLs,
then start a new chat.

+
+ + +
+ +
+ +
+ + +
+
+ +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..204b012 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}Login — KI Context Tool{% endblock %} + +{% block content %} +
+
+
+ + + +

KI Context Tool

+
+

Sign in to your account

+ +
+ {{ form.hidden_tag() }} + +
+ + {{ form.email(class="w-full bg-copilot-bg border border-copilot-border rounded-lg px-4 py-2.5 text-copilot-text focus:outline-none focus:border-copilot-accent transition", placeholder="you@example.com") }} + {% for err in form.email.errors %} +

{{ err }}

+ {% endfor %} +
+ +
+ + {{ form.password(class="w-full bg-copilot-bg border border-copilot-border rounded-lg px-4 py-2.5 text-copilot-text focus:outline-none focus:border-copilot-accent transition", placeholder="••••••••") }} + {% for err in form.password.errors %} +

{{ err }}

+ {% endfor %} +
+ + {{ form.submit(class="w-full bg-copilot-accent hover:bg-copilot-accentHover text-copilot-bg font-semibold py-2.5 rounded-lg transition cursor-pointer") }} +
+ +

+ No account? + Register +

+
+
+{% endblock %} diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..282911c --- /dev/null +++ b/templates/register.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block title %}Register — KI Context Tool{% endblock %} + +{% block content %} +
+
+
+ + + +

KI Context Tool

+
+

Create an account

+ +
+ {{ form.hidden_tag() }} + +
+ + {{ form.username(class="w-full bg-copilot-bg border border-copilot-border rounded-lg px-4 py-2.5 text-copilot-text focus:outline-none focus:border-copilot-accent transition", placeholder="johndoe") }} + {% for err in form.username.errors %} +

{{ err }}

+ {% endfor %} +
+ +
+ + {{ form.email(class="w-full bg-copilot-bg border border-copilot-border rounded-lg px-4 py-2.5 text-copilot-text focus:outline-none focus:border-copilot-accent transition", placeholder="you@example.com") }} + {% for err in form.email.errors %} +

{{ err }}

+ {% endfor %} +
+ +
+ + {{ form.password(class="w-full bg-copilot-bg border border-copilot-border rounded-lg px-4 py-2.5 text-copilot-text focus:outline-none focus:border-copilot-accent transition", placeholder="Min. 8 characters") }} + {% for err in form.password.errors %} +

{{ err }}

+ {% endfor %} +
+ +
+ + {{ form.confirm(class="w-full bg-copilot-bg border border-copilot-border rounded-lg px-4 py-2.5 text-copilot-text focus:outline-none focus:border-copilot-accent transition", placeholder="Repeat password") }} + {% for err in form.confirm.errors %} +

{{ err }}

+ {% endfor %} +
+ + {{ form.submit(class="w-full bg-copilot-accent hover:bg-copilot-accentHover text-copilot-bg font-semibold py-2.5 rounded-lg transition cursor-pointer") }} +
+ +

+ Already have an account? + Login +

+
+
+{% endblock %}