This commit is contained in:
2026-01-06 21:02:40 +01:00
commit 85d19cbaad
20 changed files with 101786 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# Use an official Python runtime as a parent image
FROM python:3.11-slim
# Set the working directory in the container
WORKDIR /app
# Copy the requirements file into the container at /app
COPY requirements.txt .
# Install any needed packages specified in requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# Copy the rest of the application's code into the container
COPY . .
# Expose the port the app runs on
EXPOSE 8000
# Define the command to run the application
# We use the PORT environment variable, defaulting to 8000
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000"]
+92
View File
@@ -0,0 +1,92 @@
import os
import uvicorn
from fastapi import FastAPI, Body
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from pydantic import BaseModel
import sys
# Import core LLM logic
from llm import load_or_train_model, generate_text, SOURCES_DIR
# --- Configuration ---
# Models to pre-load on startup
PRELOAD_N_GRAMS = [2, 3, 4, 5]
UI_DIR = "ui"
# --- Globals ---
# Cache for loaded models: {n: model}
MODEL_CACHE = {}
# --- Pydantic Models ---
class PredictRequest(BaseModel):
prompt: str
temperature: float = 0.7
n: int = 3
length: int = 5
class PredictResponse(BaseModel):
prediction: str
# --- FastAPI App ---
app = FastAPI()
def get_model_for_n(n: int):
"""
Retrieves the model for a specific N from cache, or loads/trains it.
"""
global MODEL_CACHE
if n in MODEL_CACHE:
return MODEL_CACHE[n]
print(f"Loading/Training model for N={n}...")
model = load_or_train_model(SOURCES_DIR, n)
MODEL_CACHE[n] = model
return model
@app.on_event("startup")
def startup_event():
"""
On server startup, pre-load models for all specified N-grams.
"""
print("Server starting up. Pre-loading models...")
for n in PRELOAD_N_GRAMS:
get_model_for_n(n)
print(f"Models for N={PRELOAD_N_GRAMS} loaded. Server is ready.")
@app.post("/api/predict", response_model=PredictResponse)
async def predict(request: PredictRequest):
"""
API endpoint to get the next word prediction.
"""
n = max(2, min(request.n, 5))
model = get_model_for_n(n)
if not model:
return {"prediction": ""}
length = max(1, min(request.length, 500))
prediction = generate_text(
model,
start_prompt=request.prompt,
length=length,
temperature=request.temperature
)
return PredictResponse(prediction=prediction)
# --- Static Files and Root ---
app.mount("/ui", StaticFiles(directory=UI_DIR), name="ui")
@app.get("/")
async def read_root():
return FileResponse(os.path.join(UI_DIR, "index.html"))
def run():
# Read port from environment variable, default to 8000
port = int(os.environ.get("PORT", 8000))
uvicorn.run(app, host="0.0.0.0", port=port)
if __name__ == "__main__":
run()
+13
View File
@@ -0,0 +1,13 @@
services:
stupid-llm-editor:
build: .
ports:
# Map host port 5432 to container port 8000
- "${PORT:-5432}:8000"
volumes:
- ./sources:/app/sources:ro
- ./models:/app/models
environment:
- PORT=${PORT:-8000}
stdin_open: true
tty: true
+188
View File
@@ -0,0 +1,188 @@
import os
import random
import sys
import re
import hashlib
import pickle
from collections import defaultdict, Counter
SOURCES_DIR = "sources"
CACHE_DIR = "models"
N_GRAM = 3 # Default N-gram for standalone script use
def get_dir_checksum(directory):
"""
Calculates MD5 checksum of all .txt files in the directory to detect changes.
"""
hash_md5 = hashlib.md5()
if not os.path.exists(directory):
return None
files = sorted([f for f in os.listdir(directory) if f.endswith('.txt')])
for filename in files:
filepath = os.path.join(directory, filename)
hash_md5.update(filename.encode('utf-8'))
with open(filepath, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_md5.update(chunk)
return hash_md5.hexdigest()
def train_model(sources_dir, n):
"""
Trains the N-gram model from scratch.
Returns: model object
"""
print(f"Training new {n}-gram model from sources...")
model = defaultdict(Counter)
files = [f for f in os.listdir(sources_dir) if f.endswith(".txt")]
if not files:
print("No source files found!")
return model
for filename in files:
filepath = os.path.join(sources_dir, filename)
try:
with open(filepath, 'r', encoding='utf-8') as f:
text = f.read()
text = re.sub(r'[[.*?]]', '', text)
words = text.split()
if len(words) < n:
continue
context_size = n - 1
for i in range(len(words) - context_size):
context = tuple(words[i : i + context_size])
next_word = words[i + context_size]
model[context][next_word] += 1
except Exception as e:
print(f"Error processing {filename}: {e}")
return model
def load_or_train_model(sources_dir, n):
"""
Loads model from its dedicated cache file if checksum matches, otherwise retrains.
"""
if not os.path.exists(CACHE_DIR):
os.makedirs(CACHE_DIR)
cache_file = os.path.join(CACHE_DIR, f"model_n{n}.pkl")
checksum_file = os.path.join(CACHE_DIR, f"checksum.txt") # One checksum for all
current_checksum = get_dir_checksum(sources_dir)
# Check if a model for this N exists and if the checksum matches
if os.path.exists(cache_file) and os.path.exists(checksum_file):
with open(checksum_file, 'r') as f:
saved_checksum = f.read()
if saved_checksum == current_checksum:
print(f"Sources unchanged. Loading model N={n} from {cache_file}...")
with open(cache_file, 'rb') as f:
return pickle.load(f)
else:
print(f"Sources changed. Global retrain needed. Deleting old models.")
for item in os.listdir(CACHE_DIR):
os.remove(os.path.join(CACHE_DIR, item))
print(f"No valid cache found for N={n}. Training...")
model = train_model(sources_dir, n)
print(f"Saving model to {cache_file}...")
with open(cache_file, 'wb') as f:
pickle.dump(model, f)
# Update the global checksum file after a successful train
with open(checksum_file, 'w') as f:
f.write(current_checksum or "")
return model
def generate_text(model, start_prompt, length=100, temperature=1.0):
"""
Generates text using the N-gram model.
"""
if not model:
return ""
try:
context_size = next(iter(model.keys())).__len__() # Get context size from model keys
except StopIteration:
return "" # Model is empty
start_words = start_prompt.split()
current_context = None
if len(start_words) >= context_size:
potential_context = tuple(start_words[-context_size:])
if potential_context in model:
current_context = potential_context
if current_context is None and start_words:
last_word = start_words[-1]
candidates = [k for k in model.keys() if k[0] == last_word]
if candidates:
current_context = random.choice(candidates)
if current_context is None:
current_context = random.choice(list(model.keys()))
if not start_prompt:
start_prompt = ' '.join(current_context)
generated_words = []
for _ in range(length):
if current_context not in model or not model[current_context]:
current_context = random.choice(list(model.keys()))
possible_next = list(model[current_context].keys())
counts = list(model[current_context].values())
try:
if temperature == 1.0:
weights = counts
else:
weights = [c ** (1.0 / temperature) for c in counts]
next_word = random.choices(possible_next, weights=weights, k=1)[0]
except (ValueError, IndexError):
# Fallback if weights are invalid or no words are possible
current_context = random.choice(list(model.keys()))
next_word = current_context[0]
generated_words.append(next_word)
current_context = current_context[1:] + (next_word,)
return " ".join(generated_words)
def main():
if not os.path.isdir(SOURCES_DIR):
print(f"Error: Directory '{SOURCES_DIR}' not found.")
sys.exit(1)
model = load_or_train_model(SOURCES_DIR, N_GRAM)
print(f"Model ready. (N={N_GRAM}, Keys={len(model)})")
start_prompt = ""
length = 100
temperature = 1.0
args = sys.argv[1:]
if not args:
start_ctx = random.choice(list(model.keys()))
start_prompt = " ".join(start_ctx)
else:
start_prompt = args[0]
if len(args) >= 2: length = int(args[1])
if len(args) >= 3: temperature = float(args[2])
print(f"\n--- Generating (Start: '{start_prompt}', Temp: {temperature}) ---\n")
output = start_prompt + " " + generate_text(model, start_prompt, length, temperature)
print(output)
print("\n-------------------------------------------------------------")
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
fastapi
uvicorn
python-multipart
+2429
View File
File diff suppressed because it is too large Load Diff
+10139
View File
File diff suppressed because it is too large Load Diff
+17097
View File
File diff suppressed because it is too large Load Diff
+9231
View File
File diff suppressed because it is too large Load Diff
+5528
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6545
View File
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stupid LLM Editor</title>
<link rel="stylesheet" href="/ui/style.css">
</head>
<body>
<div class="container">
<header class="header">
<h1 class="header-title">Stupid LLM Editor</h1>
<p class="header-subtitle">AI Pair-Programmer for Polish Literature</p>
</header>
<div class="card controls">
<div class="control-grid">
<div class="control-group">
<label for="n-gram">Complexity (N)</label>
<select id="n-gram" class="input-base">
<option value="2">2 (Bigram)</option>
<option value="3" selected>3 (Trigram)</option>
<option value="4">4 (Tetragram)</option>
<option value="5">5 (Pentagram)</option>
</select>
</div>
<div class="control-group">
<label for="temperature">Creativity (Temp): <span id="temp-val">0.7</span></label>
<input type="range" id="temperature" min="0.1" max="2.0" step="0.1" value="0.7">
</div>
<div class="control-group">
<label for="length">Length (Words): <span id="length-val">5</span></label>
<input type="range" id="length" min="1" max="20" step="1" value="5">
</div>
</div>
<div class="generate-action">
<button id="generate-more-btn" class="btn btn-primary">Generate Paragraph</button>
</div>
</div>
<div class="card editor-wrapper">
<div id="suggestion-overlay"></div>
<textarea id="editor" rows="1" spellcheck="false" autofocus placeholder="Start typing... Press Tab to autocomplete."></textarea>
</div>
<footer class="status-bar">
<span>Status:</span>
<span id="status">Idle</span>
</footer>
</div>
<script src="/ui/script.js"></script>
</body>
</html>
+118
View File
@@ -0,0 +1,118 @@
document.addEventListener('DOMContentLoaded', () => {
const editor = document.getElementById('editor');
const suggestionOverlay = document.getElementById('suggestion-overlay');
const status = document.getElementById('status');
// Controls
const nGramSelect = document.getElementById('n-gram');
const tempInput = document.getElementById('temperature');
const tempValDisplay = document.getElementById('temp-val');
const lengthInput = document.getElementById('length');
const lengthValDisplay = document.getElementById('length-val');
const generateBtn = document.getElementById('generate-more-btn');
let currentSuggestion = '';
let isFetching = false;
let debounceTimer;
const autoResize = () => {
editor.style.height = 'auto';
suggestionOverlay.style.height = 'auto';
const newHeight = Math.max(360, editor.scrollHeight);
editor.style.height = newHeight + 'px';
suggestionOverlay.style.height = newHeight + 'px';
};
tempInput.addEventListener('input', () => { tempValDisplay.textContent = tempInput.value; });
lengthInput.addEventListener('input', () => { lengthValDisplay.textContent = lengthInput.value; });
const triggerUpdate = () => {
currentSuggestion = '';
updateSuggestion();
const prompt = editor.value;
if (prompt.trim().length > 0) fetchPrediction(prompt);
};
nGramSelect.addEventListener('change', triggerUpdate);
tempInput.addEventListener('change', triggerUpdate);
lengthInput.addEventListener('change', triggerUpdate);
const fetchPrediction = async (prompt, customLength = null) => {
if (isFetching) return;
isFetching = true;
status.textContent = 'Thinking...';
status.classList.add('fetching');
const n = parseInt(nGramSelect.value);
const temperature = parseFloat(tempInput.value);
const length = customLength || parseInt(lengthInput.value);
try {
const response = await fetch('/api/predict', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, n, temperature, length }),
});
if (!response.ok) throw new Error('Network response failed');
const data = await response.json();
if (customLength) {
insertText(data.prediction || '');
} else {
currentSuggestion = data.prediction || '';
updateSuggestion();
}
} catch (error) {
console.error('Prediction failed:', error);
status.textContent = 'Error';
} finally {
isFetching = false;
status.textContent = 'Idle';
status.classList.remove('fetching');
}
};
const updateSuggestion = () => {
const editorText = editor.value;
const space = (editorText.length > 0 && !/\s$/.test(editorText)) ? ' ' : '';
suggestionOverlay.textContent = editorText + space + currentSuggestion;
};
const insertText = (text) => {
if (!text) return;
const space = (editor.value.length > 0 && !/\s$/.test(editor.value)) ? ' ' : '';
editor.value += space + text;
currentSuggestion = '';
updateSuggestion();
autoResize();
window.scrollTo(0, document.body.scrollHeight);
};
editor.addEventListener('input', () => {
autoResize();
clearTimeout(debounceTimer);
currentSuggestion = '';
updateSuggestion();
const prompt = editor.value;
if (prompt.trim().length === 0) return;
debounceTimer = setTimeout(() => fetchPrediction(prompt), 300);
});
editor.addEventListener('keydown', (e) => {
if (e.key === 'Tab' && currentSuggestion) {
e.preventDefault();
insertText(currentSuggestion);
fetchPrediction(editor.value);
}
});
generateBtn.addEventListener('click', () => {
fetchPrediction(editor.value, 50);
});
autoResize();
});
+168
View File
@@ -0,0 +1,168 @@
:root {
--background: hsl(0 0% 3.9%);
--foreground: hsl(0 0% 98%);
--card: hsl(0 0% 12%);
--card-foreground: hsl(0 0% 98%);
--popover: hsl(0 0% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(0 0% 9%);
--secondary: hsl(0 0% 14.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(0 0% 14.9%);
--muted-foreground: hsl(0 0% 63.9%);
--accent: hsl(0 0% 14.9%);
--accent-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 14.9%);
--input: hsl(0 0% 14.9%);
--ring: hsl(0 0% 83.1%);
--radius: 0.5rem;
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 2rem;
}
.container {
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.header {
text-align: center;
}
.header-title {
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0;
}
.header-subtitle {
color: var(--muted-foreground);
font-size: 1rem;
margin-top: 0.25rem;
}
.card {
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
}
.controls {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.control-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.control-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
label {
font-size: 0.875rem;
font-weight: 500;
}
.input-base {
background-color: var(--background);
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 2px);
color: var(--foreground);
padding: 0.5rem 0.75rem;
height: 2.5rem;
}
select.input-base {
-webkit-appearance: none;
appearance: none;
padding-right: 2rem;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='hsl(0 0% 63.9%)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3e%3cpath d='M2 5l6 6 6-6'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.5rem center;
background-size: 1em 1em;
}
.generate-action {
border-top: 1px solid var(--border);
padding-top: 1.5rem;
text-align: right;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: calc(var(--radius) - 2px);
font-size: 0.875rem;
font-weight: 600;
padding: 0.5rem 1rem;
transition: all 0.2s;
cursor: pointer;
border: none;
}
.btn-primary {
background-color: var(--primary);
color: var(--primary-foreground);
}
.btn-primary:hover {
background-color: hsl(0 0% 98% / 0.9);
}
.editor-wrapper {
position: relative;
padding: 0;
}
#editor, #suggestion-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
font-family: 'SF Mono', 'Fira Code', 'Courier New', monospace;
font-size: 1rem;
line-height: 1.7;
background-color: transparent;
border: none;
overflow: hidden;
resize: none;
padding: 1.5rem;
min-height: 300px;
}
#editor {
z-index: 2;
color: var(--foreground);
}
#editor:focus { outline: none; }
#editor::placeholder { color: var(--muted-foreground); }
#suggestion-overlay {
z-index: 1;
color: var(--muted-foreground);
pointer-events: none;
}
.status-bar {
text-align: center;
font-size: 0.8rem;
color: var(--muted-foreground);
display: flex;
gap: 0.5rem;
justify-content: center;
}
#status.fetching {
color: var(--foreground);
}