Compare commits

...
10 Commits
Author SHA1 Message Date
urieljareth 2160d4ff70 Commit inicial 2026-05-31 20:23:27 -06:00
urieljarethbusiness-cpu 7361f7ced1 1 2026-05-19 14:21:56 -06:00
urieljarethbusiness-cpu 4f3bc01dcb Update README.md 2026-05-19 13:21:06 -06:00
urieljarethbusiness-cpu 0df056445e 1 2026-05-19 11:23:47 -06:00
urieljarethbusiness-cpu d18ab1f618 Update Dockerfile.coolify 2026-05-19 10:11:36 -06:00
urieljarethbusiness-cpu b04079eaaf Update Dockerfile.coolify 2026-05-19 09:41:43 -06:00
urieljarethbusiness-cpu 7dc0f65045 1 2026-05-17 14:30:00 -06:00
urieljarethbusiness-cpu 8e2a7eadd2 Update docker-compose.yml 2026-05-17 13:18:03 -06:00
urieljarethbusiness-cpu 052580b75b 2 2026-05-17 12:57:23 -06:00
urieljarethbusiness-cpu 306d131136 Update docker-compose.yaml 2026-05-17 12:32:23 -06:00
11 changed files with 216 additions and 55 deletions
+1 -1
View File
@@ -14,6 +14,6 @@ frontend/.env.local
frontend/.env.*.local frontend/.env.*.local
node_modules node_modules
npm-debug.log* npm-debug.log*
Dockerfile* Dockerfile
docker-compose*.yml docker-compose*.yml
docker-compose*.yaml docker-compose*.yaml
+42
View File
@@ -0,0 +1,42 @@
FROM node:22-bookworm-slim AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci
COPY frontend ./
ARG NEXT_PUBLIC_API_URL=
ARG BACKEND_INTERNAL_URL=http://127.0.0.1:8000
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
RUN npm run build
FROM python:3.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV DEBIAN_FRONTEND=noninteractive
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY --from=frontend-builder /usr/local/bin/node /usr/local/bin/node
COPY --from=frontend-builder /usr/local/bin/npm /usr/local/bin/npm
COPY --from=frontend-builder /usr/local/bin/npx /usr/local/bin/npx
COPY --from=frontend-builder /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY backend ./backend
COPY scripts ./scripts
COPY --from=frontend-builder /app/frontend/.next ./frontend/.next
COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json
COPY --from=frontend-builder /app/frontend/node_modules ./frontend/node_modules
EXPOSE 3000
CMD ["bash", "scripts/start-coolify.sh"]
BIN
View File
Binary file not shown.
+9 -1
View File
@@ -8,7 +8,7 @@ from fastapi.responses import FileResponse, PlainTextResponse
from backend.app.config import settings from backend.app.config import settings
from backend.app.managers.knowledge_manager import DuplicateSourceError, KnowledgeManager from backend.app.managers.knowledge_manager import DuplicateSourceError, KnowledgeManager
from backend.app.models.schemas import MarkdownUpdate, SubjectCreate, WeekCreate, WeekUpdate from backend.app.models.schemas import MarkdownUpdate, SubjectCreate, SubjectUpdate, WeekCreate, WeekUpdate
from backend.app.services.ingestion_service import IngestionService from backend.app.services.ingestion_service import IngestionService
app = FastAPI(title="Knowledge Station", version="0.1.0") app = FastAPI(title="Knowledge Station", version="0.1.0")
@@ -60,6 +60,14 @@ def get_subject(subject_id: int) -> dict:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.put("/subjects/{subject_id}")
def update_subject(subject_id: int, payload: SubjectUpdate) -> dict:
try:
return manager.update_subject(subject_id, payload.name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.delete("/subjects/{subject_id}") @app.delete("/subjects/{subject_id}")
def delete_subject(subject_id: int) -> dict: def delete_subject(subject_id: int) -> dict:
try: try:
+26
View File
@@ -154,6 +154,14 @@ class KnowledgeManager:
raise ValueError("Materia no encontrada") raise ValueError("Materia no encontrada")
return dict(row) return dict(row)
def update_subject(self, subject_id: int, name: str) -> dict[str, Any]:
if not name.strip():
raise ValueError("El nombre de la materia no puede estar vacio")
self.get_subject(subject_id)
with self._connect() as conn:
conn.execute("update subjects set name = ? where id = ?", (name.strip(), subject_id))
return self.get_subject(subject_id)
def delete_subject(self, subject_id: int) -> None: def delete_subject(self, subject_id: int) -> None:
subject = self.get_subject(subject_id) subject = self.get_subject(subject_id)
with self._connect() as conn: with self._connect() as conn:
@@ -224,6 +232,8 @@ class KnowledgeManager:
def update_week(self, subject_id: int, current_number: int, new_number: int, title: str | None = None) -> dict[str, Any]: def update_week(self, subject_id: int, current_number: int, new_number: int, title: str | None = None) -> dict[str, Any]:
subject = self.get_subject(subject_id) subject = self.get_subject(subject_id)
week = self.get_week(subject_id, current_number) week = self.get_week(subject_id, current_number)
old_dir: Path | None = None
new_dir: Path | None = None
if new_number != current_number: if new_number != current_number:
with self._connect() as conn: with self._connect() as conn:
existing = conn.execute( existing = conn.execute(
@@ -248,6 +258,22 @@ class KnowledgeManager:
"update weeks set number = ?, title = ? where id = ?", "update weeks set number = ?, title = ? where id = ?",
(new_number, title, week["id"]), (new_number, title, week["id"]),
) )
if old_dir and new_dir:
old_prefix = str(old_dir)
new_prefix = str(new_dir)
conn.execute(
"update sources set stored_path = replace(stored_path, ?, ?) where week_id = ? and stored_path like ?",
(old_prefix, new_prefix, week["id"], f"{old_prefix}%"),
)
conn.execute(
"""
update documents
set markdown_path = replace(markdown_path, ?, ?)
where source_id in (select id from sources where week_id = ?)
and markdown_path like ?
""",
(old_prefix, new_prefix, week["id"], f"{old_prefix}%"),
)
return self.get_week(subject_id, new_number) return self.get_week(subject_id, new_number)
def week_dir(self, subject_slug: str, week_number: int) -> Path: def week_dir(self, subject_slug: str, week_number: int) -> Path:
+4
View File
@@ -8,6 +8,10 @@ class SubjectCreate(BaseModel):
name: str = Field(min_length=1) name: str = Field(min_length=1)
class SubjectUpdate(BaseModel):
name: str = Field(min_length=1)
class Subject(BaseModel): class Subject(BaseModel):
id: int id: int
name: str name: str
+9 -28
View File
@@ -1,8 +1,11 @@
services: services:
backend: app:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile.coolify
args:
NEXT_PUBLIC_API_URL: ""
BACKEND_INTERNAL_URL: http://127.0.0.1:8000
environment: environment:
APP_ENV: production APP_ENV: production
APP_DATA_DIR: /app/data APP_DATA_DIR: /app/data
@@ -10,37 +13,15 @@ services:
MISTRAL_API_KEY: ${MISTRAL_API_KEY:-} MISTRAL_API_KEY: ${MISTRAL_API_KEY:-}
MISTRAL_OCR_MODEL: ${MISTRAL_OCR_MODEL:-mistral-ocr-latest} MISTRAL_OCR_MODEL: ${MISTRAL_OCR_MODEL:-mistral-ocr-latest}
DEEPGRAM_API_KEY: ${DEEPGRAM_API_KEY:-} DEEPGRAM_API_KEY: ${DEEPGRAM_API_KEY:-}
volumes:
- station-data:/app/data
expose:
- "8000"
ports:
- "8000"
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: ""
BACKEND_INTERNAL_URL: http://backend:8000
environment:
NODE_ENV: production NODE_ENV: production
PORT: 3000 PORT: 3000
HOSTNAME: 0.0.0.0 HOSTNAME: 0.0.0.0
SERVICE_FQDN_APP: ${SERVICE_FQDN_APP:-}
SERVICE_URL_APP: ${SERVICE_URL_APP:-}
volumes:
- station-data:/app/data
expose: expose:
- "3000" - "3000"
ports:
- "3000"
depends_on:
- backend
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
+31 -15
View File
@@ -1,18 +1,34 @@
services: services:
backend: app:
build: .
ports:
- "8000:8000"
env_file:
- .env
volumes:
- ./data:/app/data
frontend:
build: build:
context: ./frontend context: .
ports: dockerfile: Dockerfile.coolify
- "3000:3000" args:
NEXT_PUBLIC_API_URL: ""
BACKEND_INTERNAL_URL: http://127.0.0.1:8000
environment: environment:
NEXT_PUBLIC_API_URL: http://127.0.0.1:8000 APP_ENV: production
depends_on: APP_DATA_DIR: /app/data
- backend DATABASE_PATH: /app/data/app.db
MISTRAL_API_KEY: ${MISTRAL_API_KEY:-}
MISTRAL_OCR_MODEL: ${MISTRAL_OCR_MODEL:-mistral-ocr-latest}
DEEPGRAM_API_KEY: ${DEEPGRAM_API_KEY:-}
NODE_ENV: production
PORT: 3000
HOSTNAME: 0.0.0.0
SERVICE_FQDN_APP: ${SERVICE_FQDN_APP:-}
SERVICE_URL_APP: ${SERVICE_URL_APP:-}
volumes:
- station-data:/app/data
expose:
- "3000"
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
volumes:
station-data:
+61 -4
View File
@@ -10,6 +10,8 @@ export default function DashboardPage() {
const [subjects, setSubjects] = useState<Subject[]>([]); const [subjects, setSubjects] = useState<Subject[]>([]);
const [settings, setSettings] = useState<SettingsStatus | null>(null); const [settings, setSettings] = useState<SettingsStatus | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [editingSubject, setEditingSubject] = useState<Subject | null>(null);
const [editName, setEditName] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -31,9 +33,47 @@ export default function DashboardPage() {
async function createSubject(event: FormEvent) { async function createSubject(event: FormEvent) {
event.preventDefault(); event.preventDefault();
if (!name.trim()) return; if (!name.trim()) return;
try {
setError("");
await api.createSubject(name.trim()); await api.createSubject(name.trim());
setName(""); setName("");
await load(); await load();
} catch (err) {
setError(err instanceof Error ? err.message : "No se pudo crear la materia");
}
}
function startEdit(subject: Subject) {
setEditingSubject(subject);
setEditName(subject.name);
setError("");
}
async function saveEdit(event: FormEvent) {
event.preventDefault();
if (!editingSubject || !editName.trim()) return;
try {
setError("");
await api.updateSubject(editingSubject.id, editName.trim());
setEditingSubject(null);
setEditName("");
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "No se pudo editar la materia");
}
}
async function deleteSubject(subject: Subject) {
const confirmed = window.confirm(`Eliminar la materia "${subject.name}" tambien borrara todas sus semanas, archivos subidos, Markdown procesado y exports. Esta accion no se puede deshacer.`);
if (!confirmed) return;
try {
setError("");
await api.deleteSubject(subject.id);
if (editingSubject?.id === subject.id) setEditingSubject(null);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "No se pudo eliminar la materia");
}
} }
return ( return (
@@ -50,13 +90,15 @@ export default function DashboardPage() {
{loading ? <Card>Cargando...</Card> : ( {loading ? <Card>Cargando...</Card> : (
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{subjects.map((subject) => ( {subjects.map((subject) => (
<Link key={subject.id} href={`/subjects/${subject.id}`}> <Card key={subject.id} className="transition hover:border-blue-400/40 hover:bg-blue-500/10">
<Card className="transition hover:border-blue-400/40 hover:bg-blue-500/10">
<div className="text-xl font-semibold">{subject.name}</div> <div className="text-xl font-semibold">{subject.name}</div>
<div className="mt-2 text-sm text-slate-400">{subject.slug}</div> <div className="mt-2 text-sm text-slate-400">{subject.slug}</div>
<div className="mt-5 text-sm text-blue-300">Abrir materia</div> <div className="mt-5 flex flex-wrap gap-2 text-sm">
<Link className="rounded-lg bg-blue-600 px-3 py-2 font-medium text-white hover:bg-blue-500" href={`/subjects/${subject.id}`}>Abrir materia</Link>
<button className="rounded-lg border border-white/10 px-3 py-2 text-slate-200 hover:bg-white/10" type="button" onClick={() => startEdit(subject)}>Editar</button>
<button className="rounded-lg border border-red-400/30 px-3 py-2 text-red-200 hover:bg-red-500/10" type="button" onClick={() => deleteSubject(subject)}>Eliminar</button>
</div>
</Card> </Card>
</Link>
))} ))}
{subjects.length === 0 && <Card className="md:col-span-2">No hay materias todavia. Crea la primera para empezar.</Card>} {subjects.length === 0 && <Card className="md:col-span-2">No hay materias todavia. Crea la primera para empezar.</Card>}
</div> </div>
@@ -64,6 +106,21 @@ export default function DashboardPage() {
</section> </section>
<aside className="space-y-4"> <aside className="space-y-4">
{editingSubject && (
<Card>
<h2 className="text-lg font-semibold">Editar materia</h2>
<p className="mt-1 text-sm text-slate-400">Solo cambia el nombre visible. La carpeta interna queda igual para mantener rutas estables.</p>
<form className="mt-4 space-y-3" onSubmit={saveEdit}>
<Label>Nombre</Label>
<Input value={editName} onChange={(event) => setEditName(event.target.value)} />
<div className="flex gap-2">
<Button className="flex-1" disabled={!editName.trim()}>Guardar</Button>
<button className="flex-1 rounded-xl border border-white/10 px-4 py-2 hover:bg-white/10" type="button" onClick={() => setEditingSubject(null)}>Cancelar</button>
</div>
</form>
</Card>
)}
<Card> <Card>
<h2 className="text-lg font-semibold">Nueva materia</h2> <h2 className="text-lg font-semibold">Nueva materia</h2>
<form className="mt-4 space-y-3" onSubmit={createSubject}> <form className="mt-4 space-y-3" onSubmit={createSubject}>
@@ -62,6 +62,19 @@ export default function SubjectPage({ params }: { params: { subjectId: string }
} }
} }
async function deleteWeek(week: Week) {
const confirmed = window.confirm(`Eliminar la semana ${week.number} tambien borrara sus archivos subidos, Markdown procesado y exports. Esta accion no se puede deshacer.`);
if (!confirmed) return;
try {
setError("");
await api.deleteWeek(subjectId, week.number);
if (editingWeek?.id === week.id) setEditingWeek(null);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "No se pudo eliminar la semana");
}
}
return ( return (
<Shell> <Shell>
<Link href="/" className="text-sm text-blue-300">Volver a materias</Link> <Link href="/" className="text-sm text-blue-300">Volver a materias</Link>
@@ -78,6 +91,7 @@ export default function SubjectPage({ params }: { params: { subjectId: string }
<div className="mt-5 flex flex-wrap gap-2 text-sm"> <div className="mt-5 flex flex-wrap gap-2 text-sm">
<Link className="rounded-lg bg-blue-600 px-3 py-2 font-medium text-white hover:bg-blue-500" href={`/subjects/${subjectId}/weeks/${week.number}`}>Abrir semana</Link> <Link className="rounded-lg bg-blue-600 px-3 py-2 font-medium text-white hover:bg-blue-500" href={`/subjects/${subjectId}/weeks/${week.number}`}>Abrir semana</Link>
<button className="rounded-lg border border-white/10 px-3 py-2 text-slate-200 hover:bg-white/10" type="button" onClick={() => startEdit(week)}>Editar</button> <button className="rounded-lg border border-white/10 px-3 py-2 text-slate-200 hover:bg-white/10" type="button" onClick={() => startEdit(week)}>Editar</button>
<button className="rounded-lg border border-red-400/30 px-3 py-2 text-red-200 hover:bg-red-500/10" type="button" onClick={() => deleteWeek(week)}>Eliminar</button>
</div> </div>
</Card> </Card>
))} ))}
+13
View File
@@ -65,6 +65,16 @@ export const api = {
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
})); }));
}, },
async updateSubject(id: number, name: string) {
return parseResponse<Subject>(await fetch(`${API_URL}/subjects/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
}));
},
async deleteSubject(id: number) {
return parseResponse<{ status: string }>(await fetch(`${API_URL}/subjects/${id}`, { method: "DELETE" }));
},
async weeks(subjectId: number) { async weeks(subjectId: number) {
return parseResponse<Week[]>(await fetch(`${API_URL}/subjects/${subjectId}/weeks`, { cache: "no-store" })); return parseResponse<Week[]>(await fetch(`${API_URL}/subjects/${subjectId}/weeks`, { cache: "no-store" }));
}, },
@@ -85,6 +95,9 @@ export const api = {
body: JSON.stringify({ number, title: title || null }), body: JSON.stringify({ number, title: title || null }),
})); }));
}, },
async deleteWeek(subjectId: number, weekNumber: number) {
return parseResponse<{ status: string }>(await fetch(`${API_URL}/subjects/${subjectId}/weeks/${weekNumber}`, { method: "DELETE" }));
},
async sources(subjectId: number, weekNumber: number) { async sources(subjectId: number, weekNumber: number) {
return parseResponse<Source[]>(await fetch(`${API_URL}/subjects/${subjectId}/weeks/${weekNumber}/sources`, { cache: "no-store" })); return parseResponse<Source[]>(await fetch(`${API_URL}/subjects/${subjectId}/weeks/${weekNumber}/sources`, { cache: "no-store" }));
}, },