update
This commit is contained in:
@@ -25,6 +25,11 @@ RUN useradd -m -u 1000 app
|
||||
COPY backend/requirements.txt /app/backend/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /app/backend/requirements.txt
|
||||
|
||||
# Chromium + OS-Libs für Playwright (als root) in ein gemeinsames, welt-lesbares Verzeichnis.
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||
RUN python3 -m playwright install --with-deps chromium \
|
||||
&& chmod -R a+rX /ms-playwright
|
||||
|
||||
COPY --chown=app:app backend/ /app/backend/
|
||||
COPY --chown=app:app templates/ /app/templates/
|
||||
COPY --chown=app:app --from=frontend /build/dist /app/frontend/dist
|
||||
|
||||
4
Makefile
4
Makefile
@@ -12,7 +12,9 @@ auth:
|
||||
@echo "Verzeichnisse angelegt und auf uid 1000 chowned."
|
||||
|
||||
install:
|
||||
pip install --break-system-packages fastapi uvicorn[standard] aiosqlite uv
|
||||
pip install --break-system-packages fastapi uvicorn[standard] aiosqlite uv playwright
|
||||
python3 -m playwright install chromium
|
||||
@echo "Falls Chromium OS-Libs fehlen: 'sudo python3 -m playwright install-deps chromium' einmalig ausführen."
|
||||
@which pdftotext >/dev/null 2>&1 || sudo apt-get install -y poppler-utils
|
||||
cd frontend && npm install
|
||||
npm install -g opencode-ai
|
||||
|
||||
@@ -21,7 +21,8 @@ from agents import kill_process
|
||||
from config import KONSENS_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER
|
||||
from fsutil import atomic_write_text, atomic_write_json
|
||||
from jsonio import read_json_file as _json_datei
|
||||
from paths import arbeit_dir, bausteine_path, project_dir, subbausteine_path
|
||||
from paths import arbeit_dir, bausteine_path, project_dir, subbausteine_path, quelle_path, quelle_crawl_dir, safe_ordner
|
||||
from crawl import crawl
|
||||
from pipeline import (
|
||||
CANCELLED, FAILED, GenContext, _extra, _log, _prompt, _race, _rest_schema,
|
||||
_runde_schema, _semaphore, _str_liste, _stufen_schema, _timeout, run_single_slot,
|
||||
@@ -52,20 +53,45 @@ BAUSTEINE_STEPS = (
|
||||
)
|
||||
|
||||
|
||||
def lade_quelle(topic: str) -> dict:
|
||||
"""Persistierte Quellen-Wahl lesen. Fallback (Alt-Themen ohne quelle.json):
|
||||
existiert projects/<topic> → projekt, sonst thema."""
|
||||
q = _json_datei(quelle_path(topic))
|
||||
if isinstance(q, dict) and q.get("type") in ("thema", "projekt", "uni", "link"):
|
||||
return q
|
||||
if project_dir(topic).is_dir():
|
||||
return {"type": "projekt", "ort": f"projects/{topic}", "spec": ""}
|
||||
return {"type": "thema", "ort": "", "spec": ""}
|
||||
|
||||
|
||||
def quelle_ordner(topic: str) -> Path | None:
|
||||
"""Ordner-Quelle (projekt/uni → Pfad, link → Crawl-Ordner) — sonst None (thema)."""
|
||||
q = lade_quelle(topic)
|
||||
if q["type"] == "link":
|
||||
return quelle_crawl_dir(topic)
|
||||
if q["type"] in ("projekt", "uni"):
|
||||
return safe_ordner(q.get("ort", ""))
|
||||
return None
|
||||
|
||||
|
||||
def _crawl_fertig(topic: str) -> bool:
|
||||
return (quelle_crawl_dir(topic) / ".done").exists() # Marker erst bei sauberem Abschluss
|
||||
|
||||
|
||||
def _bausteine_steps(topic: str) -> tuple:
|
||||
"""Projekte haben einen zusätzlichen Schritt (Ergänzung), nach der Klärung eingefügt.
|
||||
"""Schritte je Quelle: link bekommt vorne „Quelle laden", projekt zusätzlich „Ergänzung".
|
||||
|
||||
Subbausteine + Stufen sind je drei Phasen (Finden, Wählen, Klären). Pro Phase
|
||||
laufen alle Pakete parallel; der Schritt bleibt, bis das letzte Paket fertig ist.
|
||||
"""
|
||||
q = lade_quelle(topic)
|
||||
base = ("Recherche", "Konsolidierung", "Klärung")
|
||||
rest = (
|
||||
"Subbausteine finden", "Subbausteine wählen", "Subbausteine klären",
|
||||
"Stufen finden", "Stufen wählen", "Stufen klären",
|
||||
)
|
||||
if project_dir(topic).is_dir():
|
||||
return base + ("Ergänzung",) + rest
|
||||
return base + rest
|
||||
mitte = base + (("Ergänzung",) if q["type"] == "projekt" else ()) + rest
|
||||
return (("Quelle laden",) if q["type"] == "link" else ()) + mitte
|
||||
|
||||
|
||||
def _step_idx(topic: str, name: str) -> int:
|
||||
@@ -111,18 +137,21 @@ def cancel_bausteine(topic: str) -> bool:
|
||||
def _resume_step(topic: str) -> int:
|
||||
"""Erster noch offener Schritt anhand der persistierten Zwischendateien."""
|
||||
files = _bausteine_files(topic)
|
||||
q = lade_quelle(topic)
|
||||
if q["type"] == "link" and not _crawl_fertig(topic):
|
||||
return _step_idx(topic, "Quelle laden")
|
||||
if sum(p.exists() for p in files["recherche"]) < 3:
|
||||
return 0
|
||||
return _step_idx(topic, "Recherche")
|
||||
if not files["recherche_mapping"].exists():
|
||||
return 1
|
||||
return _step_idx(topic, "Konsolidierung")
|
||||
mapping = _mapping_schema(_json_datei(files["recherche_mapping"]))
|
||||
geklaert = mapping is not None and (
|
||||
not mapping[1] # kein strittiger Rest
|
||||
or any((r := _runde_schema(_json_datei(p))) is not None and not r[1] for p in files["mapping"].values())
|
||||
)
|
||||
if not geklaert:
|
||||
return 2
|
||||
if project_dir(topic).is_dir() and not files["ergaenzung"].exists():
|
||||
return _step_idx(topic, "Klärung")
|
||||
if q["type"] == "projekt" and not files["ergaenzung"].exists():
|
||||
return _step_idx(topic, "Ergänzung")
|
||||
if _sidecar_schema(_json_datei(files["sidecar"])) is not None:
|
||||
return len(_bausteine_steps(topic))
|
||||
@@ -166,6 +195,8 @@ def reset_bausteine(topic: str) -> None:
|
||||
files = _bausteine_files(topic)
|
||||
files["final"].unlink(missing_ok=True)
|
||||
files["sidecar"].unlink(missing_ok=True) # liegt im Themen-Root, nicht in arbeit/
|
||||
quelle_path(topic).unlink(missing_ok=True)
|
||||
shutil.rmtree(quelle_crawl_dir(topic), ignore_errors=True) # gecrawlte Link-Quelle
|
||||
shutil.rmtree(files["arbeit"], ignore_errors=True)
|
||||
_bausteine_errors.pop(topic, None)
|
||||
|
||||
@@ -209,9 +240,12 @@ def _pdfs_konvertieren(project: Path) -> None:
|
||||
raise RuntimeError(f"PDF-Konvertierung fehlgeschlagen ({pdf.name}): {e}") from e
|
||||
|
||||
|
||||
def _build_recherche_prompt(topic: str, out_path: Path, instructions: str = "", project: Path | None = None) -> str:
|
||||
if project:
|
||||
source = _prompt("Bausteine-Quelle-Projekt", project=project)
|
||||
_QUELLE_TEMPLATE = {"projekt": "Bausteine-Quelle-Projekt", "uni": "Bausteine-Quelle-Uni", "link": "Bausteine-Quelle-Link"}
|
||||
|
||||
|
||||
def _build_recherche_prompt(topic: str, out_path: Path, instructions: str, typ: str, ordner: Path | None) -> str:
|
||||
if typ in _QUELLE_TEMPLATE:
|
||||
source = _prompt(_QUELLE_TEMPLATE[typ], project=ordner)
|
||||
else:
|
||||
source = _prompt("Bausteine-Quelle-Thema", topic=topic)
|
||||
return _prompt(
|
||||
@@ -337,7 +371,7 @@ async def _subbausteine_block(ctx: GenContext, set_p, files: dict, entries: dict
|
||||
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
|
||||
arbeit = files["arbeit"]
|
||||
idx = _titel_index(entries)
|
||||
caps = "files" if project_dir(topic).is_dir() else "full"
|
||||
caps = "files" if quelle_ordner(topic) else "full"
|
||||
nums = list(entries)
|
||||
chunks = _chunk_nums(nums, _n_chunks(len(nums)))
|
||||
n = len(chunks)
|
||||
@@ -548,7 +582,9 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
||||
|
||||
files = _bausteine_files(topic)
|
||||
final_path = files["final"]
|
||||
project = project_dir(topic) if project_dir(topic).is_dir() else None
|
||||
q = lade_quelle(topic)
|
||||
ordner = quelle_ordner(topic) # projekt/uni/link → Ordner, thema → None
|
||||
instructions = q.get("spec") or instructions # persistierte Spezifikation bevorzugen (auch bei Resume)
|
||||
|
||||
def set_p(msg: str, step: int | None = None) -> None:
|
||||
_bausteine_progress[topic] = msg
|
||||
@@ -566,8 +602,18 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
||||
try:
|
||||
async with _semaphore:
|
||||
files["arbeit"].mkdir(parents=True, exist_ok=True)
|
||||
if project:
|
||||
await asyncio.to_thread(_pdfs_konvertieren, project)
|
||||
# Link-Quelle: erst crawlen (gleiche Domain, begrenzt) → wird zur Ordner-Quelle.
|
||||
if q["type"] == "link" and not _crawl_fertig(topic):
|
||||
set_p("Quelle laden (Crawl)…", step=_step_idx(topic, "Quelle laden"))
|
||||
n = await asyncio.to_thread(crawl, q["ort"], ordner, cancelled=is_cancelled)
|
||||
if is_cancelled():
|
||||
abgebrochen()
|
||||
return
|
||||
if not n:
|
||||
_bausteine_errors[topic] = "Crawl ergab keine Inhalte — Link/Domain prüfen"
|
||||
return
|
||||
if ordner:
|
||||
await asyncio.to_thread(_pdfs_konvertieren, ordner)
|
||||
# „Neu erstellen": NUR wenn wirklich alles fertig ist (bausteine.md UND
|
||||
# Sidecar) → kompletter Frischstart. Liegt bausteine.md ohne Sidecar vor,
|
||||
# ist das ein Teil-Stand (Block B/C offen) → Resume, nicht wischen.
|
||||
@@ -587,13 +633,13 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
||||
else:
|
||||
offen.append((i, path))
|
||||
vorhanden = len(recherchen)
|
||||
set_p(f"Recherche läuft ({vorhanden} gültig, min. 3)…", step=0)
|
||||
set_p(f"Recherche läuft ({vorhanden} gültig, min. 3)…", step=_step_idx(topic, "Recherche"))
|
||||
if vorhanden < 3:
|
||||
caps = "files" if project else "full"
|
||||
caps = "files" if ordner else "full"
|
||||
slots = [
|
||||
{
|
||||
"key": f"bausteine-{topic}-recherche-{i}",
|
||||
"prompt": _build_recherche_prompt(topic, path, instructions, project),
|
||||
"prompt": _build_recherche_prompt(topic, path, instructions, q["type"], ordner),
|
||||
"role": "quick", "capabilities": caps,
|
||||
"payload": (lambda result, p=path: _file_payload(p)),
|
||||
}
|
||||
@@ -616,7 +662,7 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
||||
# für semantische Dubletten und Konsens/Rest-Teilung (fatal)
|
||||
mapping = _mapping_schema(_json_datei(files["recherche_mapping"]))
|
||||
if mapping is None:
|
||||
set_p("Konsolidiere Recherche…", step=1)
|
||||
set_p("Konsolidiere Recherche…", step=_step_idx(topic, "Konsolidierung"))
|
||||
files["recherche_mapping"].unlink(missing_ok=True)
|
||||
gemergt = _vormerge([_parse_auswahl(t) for t in recherchen])
|
||||
eintraege = "\n".join(f"{i}. {text} ({n}× genannt)" for i, (text, n) in enumerate(gemergt, 1))
|
||||
@@ -648,7 +694,7 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
||||
while rest and runde < KONSENS_MAX_RUNDEN:
|
||||
runde += 1
|
||||
final_runde = runde == KONSENS_MAX_RUNDEN
|
||||
set_p(f"Klärung läuft (Runde {runde}/{KONSENS_MAX_RUNDEN})…", step=2)
|
||||
set_p(f"Klärung läuft (Runde {runde}/{KONSENS_MAX_RUNDEN})…", step=_step_idx(topic, "Klärung"))
|
||||
mapping_path = files["mapping"][runde]
|
||||
|
||||
# Resume: fertiges Runden-Mapping wird direkt übernommen
|
||||
@@ -732,8 +778,8 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
||||
|
||||
# Nur Projekte: Themenfeld-Ergänzung — Skript/Projekt ist ein Ausschnitt,
|
||||
# ein Web-Agent ergänzt kanonisch fehlende Bausteine, markiert mit [Ergänzung].
|
||||
if project:
|
||||
set_p("Ergänze Themenfeld…", step=3)
|
||||
if q["type"] == "projekt":
|
||||
set_p("Ergänze Themenfeld…", step=_step_idx(topic, "Ergänzung"))
|
||||
erg_path = files["ergaenzung"]
|
||||
ergaenzungen = _ergaenzung_schema(_json_datei(erg_path))
|
||||
if ergaenzungen is None:
|
||||
|
||||
113
backend/crawl.py
Normal file
113
backend/crawl.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Geboundeter Domain-Crawler für Link-Quellen — rendert JS via Playwright (Chromium).
|
||||
|
||||
Lädt ab einer Start-URL Seiten + PDFs — NUR dieselbe Domain, begrenzte Tiefe und
|
||||
Seitenzahl. HTML-Seiten werden im Headless-Browser gerendert (für SPAs nötig), dann
|
||||
Links + Text aus dem fertigen DOM gezogen. PDFs werden direkt als Bytes geladen.
|
||||
Deterministisch, gebounded; läuft via asyncio.to_thread (Sync-API, kein Event-Loop).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urldefrag, urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from fsutil import atomic_write_text
|
||||
|
||||
log = logging.getLogger("creator.crawl")
|
||||
|
||||
MAX_TIEFE = 3
|
||||
MAX_SEITEN = 500
|
||||
SEITE_TIMEOUT = 30 # Sekunden pro Seite (Render bzw. PDF-Download)
|
||||
MAX_BYTES = 10_000_000 # 10 MB Deckel pro PDF
|
||||
_UA = "Mozilla/5.0 (creator-lernbot)"
|
||||
|
||||
|
||||
def _fetch_bytes(url: str) -> bytes | None:
|
||||
"""PDF-Bytes per urllib laden (kein Rendering nötig). None bei Fehler/zu groß."""
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": _UA})
|
||||
with urlopen(req, timeout=SEITE_TIMEOUT) as resp:
|
||||
data = resp.read(MAX_BYTES + 1)
|
||||
return None if len(data) > MAX_BYTES else data
|
||||
except Exception as e:
|
||||
log.debug("crawl: PDF-Fetch fehlgeschlagen %s: %s", url, e)
|
||||
return None
|
||||
|
||||
|
||||
def _name(url: str, ext: str) -> str:
|
||||
h = hashlib.md5(url.encode("utf-8")).hexdigest()[:8]
|
||||
slug = re.sub(r"[^a-zA-Z0-9]+", "-", urlparse(url).path).strip("-")[:60] or "index"
|
||||
return f"{h}-{slug}{ext}"
|
||||
|
||||
|
||||
def _is_pdf(url: str) -> bool:
|
||||
return url.lower().split("?")[0].rstrip("/").endswith(".pdf")
|
||||
|
||||
|
||||
def crawl(start_url: str, ziel: Path, *, max_tiefe: int = MAX_TIEFE, max_seiten: int = MAX_SEITEN, cancelled=None) -> int:
|
||||
"""Crawlt ab start_url (nur gleiche Domain), rendert JS und legt Seiten/PDFs in `ziel` ab.
|
||||
|
||||
BFS bis `max_tiefe` / `max_seiten`. Fehler einzelner Seiten werden übersprungen.
|
||||
Schreibt am ENDE einen `.done`-Marker; ein Abbruch (`cancelled()` → True) lässt ihn weg,
|
||||
sodass ein Neustart neu crawlt. Gibt die Zahl gespeicherter Quellen zurück.
|
||||
"""
|
||||
# Lazy: so startet das Backend auch ohne installiertes Playwright; nur das Crawlen schlägt dann fehl.
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ziel.mkdir(parents=True, exist_ok=True)
|
||||
domain = urlparse(start_url).netloc
|
||||
gesehen: set[str] = set()
|
||||
queue: list[tuple[str, int]] = [(urldefrag(start_url)[0], 0)]
|
||||
gespeichert = 0
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(args=["--no-sandbox"]) # non-root (Docker user app)
|
||||
page = browser.new_page(user_agent=_UA)
|
||||
try:
|
||||
while queue and gespeichert < max_seiten:
|
||||
if cancelled and cancelled():
|
||||
return gespeichert # Abbruch → KEIN .done-Marker → Neustart crawlt neu
|
||||
url, tiefe = queue.pop(0)
|
||||
if url in gesehen:
|
||||
continue
|
||||
gesehen.add(url)
|
||||
|
||||
# PDFs brauchen kein Rendering — direkt laden.
|
||||
if _is_pdf(url):
|
||||
data = _fetch_bytes(url)
|
||||
if data:
|
||||
p = ziel / _name(url, ".pdf")
|
||||
if not p.exists():
|
||||
p.write_bytes(data)
|
||||
gespeichert += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
page.goto(url, wait_until="networkidle", timeout=SEITE_TIMEOUT * 1000)
|
||||
except Exception as e:
|
||||
log.debug("crawl: goto unvollständig %s: %s", url, e) # trotzdem versuchen, Inhalt zu lesen
|
||||
try:
|
||||
text = page.inner_text("body").strip()
|
||||
except Exception:
|
||||
text = ""
|
||||
if text:
|
||||
atomic_write_text(ziel / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}")
|
||||
gespeichert += 1
|
||||
if tiefe < max_tiefe:
|
||||
try:
|
||||
hrefs = page.eval_on_selector_all("a[href]", "els => els.map(e => e.href)")
|
||||
except Exception:
|
||||
hrefs = []
|
||||
for href in hrefs:
|
||||
nxt = urldefrag(href)[0]
|
||||
if (nxt.startswith(("http://", "https://"))
|
||||
and urlparse(nxt).netloc == domain and nxt not in gesehen):
|
||||
queue.append((nxt, tiefe + 1))
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
(ziel / ".done").write_text("ok", encoding="utf-8") # sauber durchgelaufen
|
||||
log.info("crawl %s → %d Quellen in %s", start_url, gespeichert, ziel)
|
||||
return gespeichert
|
||||
@@ -15,7 +15,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from agents import run_agent
|
||||
from bausteine import _pdfs_konvertieren
|
||||
from bausteine import _pdfs_konvertieren, quelle_ordner
|
||||
from config import (
|
||||
DEFAULT_PROVIDER, FORMAT_ANTEIL, KONSENS_GRACE, KONSENS_MAX_RUNDEN,
|
||||
TEMPLATES_DIR,
|
||||
@@ -771,7 +771,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
|
||||
|
||||
content_path = guide_content_path(topic, format_name)
|
||||
content_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
project = project_dir(topic) if project_dir(topic).is_dir() else None
|
||||
project = quelle_ordner(topic) # Ordner-Quelle (projekt/uni/link) → Pfad, sonst None
|
||||
|
||||
try:
|
||||
if is_guide_cancelled(guide_id):
|
||||
|
||||
@@ -10,6 +10,8 @@ FormatType = Literal[
|
||||
|
||||
ProviderType = Literal["claude", "minimax", "lokal"]
|
||||
|
||||
SourceType = Literal["thema", "projekt", "uni", "link"]
|
||||
|
||||
|
||||
class GuideCreateRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
@@ -26,6 +28,8 @@ class BausteineCreateRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
instructions: str = Field(default="", max_length=2000)
|
||||
provider: ProviderType = "claude"
|
||||
source_type: SourceType = "thema"
|
||||
source_ort: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
class BausteineStep(BaseModel):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from pathlib import Path
|
||||
|
||||
from config import STORAGE_DIR, PROJECTS_DIR
|
||||
from config import STORAGE_DIR, PROJECTS_DIR, PROJECT_ROOT
|
||||
|
||||
THEMEN_DIR = STORAGE_DIR / "themen"
|
||||
|
||||
@@ -26,6 +26,28 @@ def subbausteine_path(topic: str) -> Path:
|
||||
return topic_dir(topic) / "subbausteine.json"
|
||||
|
||||
|
||||
def quelle_path(topic: str) -> Path:
|
||||
"""Persistierte Quellen-Wahl pro Thema: {type, ort, spec}."""
|
||||
return topic_dir(topic) / "quelle.json"
|
||||
|
||||
|
||||
def quelle_crawl_dir(topic: str) -> Path:
|
||||
"""Zielordner für gecrawlte Link-Quellen (Seiten + PDF-.txt)."""
|
||||
return topic_dir(topic) / "quelle"
|
||||
|
||||
|
||||
def safe_ordner(ort: str) -> Path | None:
|
||||
"""Ordnerpfad relativ zum Repo-Root, gesandboxt. None bei leer/Ausbruch (../, absolut außerhalb)."""
|
||||
if not ort or not ort.strip():
|
||||
return None
|
||||
p = (PROJECT_ROOT / ort.strip()).resolve()
|
||||
try:
|
||||
p.relative_to(PROJECT_ROOT)
|
||||
except ValueError:
|
||||
return None
|
||||
return p
|
||||
|
||||
|
||||
def guide_content_path(topic: str, format_name: str) -> Path:
|
||||
return topic_dir(topic) / "guides" / f"{format_name}.json"
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
aiosqlite
|
||||
playwright
|
||||
|
||||
@@ -35,7 +35,8 @@ from models import (
|
||||
BausteinChatRequest, BausteinChatResponse,
|
||||
BausteinPruefungRequest, BausteinPruefungResponse, BausteinLernstandResponse,
|
||||
)
|
||||
from paths import bausteine_topics, guide_content_path, project_dir, topic_dir
|
||||
from paths import bausteine_topics, guide_content_path, project_dir, topic_dir, quelle_path, safe_ordner
|
||||
from fsutil import atomic_write_json
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -130,6 +131,19 @@ async def create_bausteine(req: BausteineCreateRequest):
|
||||
if bausteine_status(topic)["generating"]:
|
||||
return {"ok": True, "status": "already_generating"}
|
||||
await create_topic(topic)
|
||||
qp = quelle_path(topic)
|
||||
# Quelle nur beim ERSTEN Mal festschreiben; ▶/Resume erhält die bestehende Wahl.
|
||||
if not qp.exists():
|
||||
typ, ort = req.source_type, req.source_ort.strip()
|
||||
if typ in ("projekt", "uni"):
|
||||
ordner = safe_ordner(ort)
|
||||
if ordner is None or not ordner.is_dir():
|
||||
raise HTTPException(400, "Ordner ungültig oder nicht gefunden (Pfad relativ zum Projekt-Root, kein ../).")
|
||||
elif typ == "link":
|
||||
if not ort.lower().startswith(("http://", "https://")):
|
||||
raise HTTPException(400, "Link muss mit http:// oder https:// beginnen.")
|
||||
qp.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(qp, {"type": typ, "ort": ort, "spec": req.instructions.strip()})
|
||||
asyncio.create_task(generate_bausteine(topic, req.instructions.strip(), req.provider))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -245,6 +245,19 @@ async function handleBausteineClick({ instructions }) {
|
||||
startPolling()
|
||||
}
|
||||
|
||||
async function handleCreateThema({ topic, instructions, sourceType, sourceOrt }) {
|
||||
uiError.value = null
|
||||
try {
|
||||
await apiCreateBausteine(topic, instructions, provider.value, sourceType, sourceOrt)
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
return
|
||||
}
|
||||
await loadTopics()
|
||||
selectTopic(topic)
|
||||
startPolling()
|
||||
}
|
||||
|
||||
async function handleFormatClick({ format, instructions }) {
|
||||
if (!selectedTopic.value) return
|
||||
// Kein Duplikat-Start: läuft für Thema+Format schon eine Generierung, ignorieren
|
||||
@@ -365,6 +378,7 @@ onMounted(async () => {
|
||||
@toggleDark="toggleDark"
|
||||
@select="selectTopic"
|
||||
@create="createTopic"
|
||||
@createThema="handleCreateThema"
|
||||
@formatClick="handleFormatClick"
|
||||
@bausteineClick="handleBausteineClick"
|
||||
@cancelBausteine="handleCancelBausteine"
|
||||
|
||||
@@ -42,11 +42,11 @@ export async function fetchBausteineStatus(topic) {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function createBausteine(topic, instructions = '', provider = 'claude') {
|
||||
export async function createBausteine(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '') {
|
||||
const res = await fetch(`${BASE}/bausteine`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, instructions, provider }),
|
||||
body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_ort: sourceOrt }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const props = defineProps({
|
||||
providers: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'create', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setProvider'])
|
||||
const emit = defineEmits(['select', 'create', 'createThema', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setProvider'])
|
||||
|
||||
function providerAvailable(id) {
|
||||
const p = props.providers.find((x) => x.id === id)
|
||||
@@ -176,13 +176,36 @@ function handleDelete(format) {
|
||||
})
|
||||
}
|
||||
|
||||
const newTopic = ref('')
|
||||
// Erstellen-Bereich: inline aufklappbar (Name + weitere Infos + Quellen-Typ).
|
||||
const dlg = ref(false)
|
||||
const form = ref({ name: '', instructions: '', sourceType: 'thema', sourceOrt: '' })
|
||||
const SOURCE_HINTS = {
|
||||
thema: 'Web-Recherche zum Thema.',
|
||||
link: 'Seite wird gecrawlt (gleiche Domain, begrenzt) — Klausur-Fokus.',
|
||||
projekt: 'Ordner wird gelesen — Architektur/Features verstehen.',
|
||||
uni: 'Ordner wird gelesen — Klausur-Vorbereitung.',
|
||||
}
|
||||
const sourceHint = computed(() => SOURCE_HINTS[form.value.sourceType])
|
||||
const canCreate = computed(() => {
|
||||
if (!form.value.name.trim()) return false
|
||||
if (['link', 'projekt', 'uni'].includes(form.value.sourceType)) return !!form.value.sourceOrt.trim()
|
||||
return true
|
||||
})
|
||||
|
||||
function submit() {
|
||||
const t = newTopic.value.trim()
|
||||
if (!t) return
|
||||
emit('create', t)
|
||||
newTopic.value = ''
|
||||
function toggleErstellen() {
|
||||
if (!dlg.value) form.value = { name: '', instructions: '', sourceType: 'thema', sourceOrt: '' }
|
||||
dlg.value = !dlg.value
|
||||
}
|
||||
|
||||
function createThema() {
|
||||
if (!canCreate.value) return
|
||||
emit('createThema', {
|
||||
topic: form.value.name.trim(),
|
||||
instructions: form.value.instructions.trim(),
|
||||
sourceType: form.value.sourceType,
|
||||
sourceOrt: form.value.sourceOrt.trim(),
|
||||
})
|
||||
dlg.value = false
|
||||
}
|
||||
|
||||
function confirmDeleteTopic(topic) {
|
||||
@@ -213,12 +236,34 @@ function confirmDeleteProject(name) {
|
||||
:title="dark ? 'Hellmodus' : 'Dunkelmodus'"
|
||||
@click="emit('toggleDark')"
|
||||
>{{ dark ? '☀' : '🌙' }}</button>
|
||||
<button class="new-topic-toggle" :class="{ active: dlg }" title="Thema erstellen" @click="toggleErstellen">+</button>
|
||||
</div>
|
||||
|
||||
<!-- Erstellen: inline aufklappbar (kein Modal) -->
|
||||
<div v-if="dlg" class="thema-panel">
|
||||
<input class="dlg-input" v-model="form.name" placeholder="Thema-Name…" @keyup.enter="createThema" autofocus />
|
||||
<textarea class="dlg-textarea" v-model="form.instructions" rows="3" placeholder="Weitere Informationen / Spezifikation (optional)…"></textarea>
|
||||
<div class="dlg-sources">
|
||||
<button :class="{ active: form.sourceType === 'thema' }" @click="form.sourceType = 'thema'; form.sourceOrt = ''">Thema</button>
|
||||
<button :class="{ active: form.sourceType === 'link' }" @click="form.sourceType = 'link'; form.sourceOrt = ''">Link</button>
|
||||
<button :class="{ active: form.sourceType === 'projekt' }" @click="form.sourceType = 'projekt'; form.sourceOrt = ''">Projekt</button>
|
||||
<button :class="{ active: form.sourceType === 'uni' }" @click="form.sourceType = 'uni'; form.sourceOrt = ''">Uni</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="newTopic"
|
||||
placeholder="Neues Thema…"
|
||||
@keyup.enter="submit"
|
||||
v-if="form.sourceType === 'link'"
|
||||
class="dlg-input" v-model="form.sourceOrt"
|
||||
placeholder="https://…" @keyup.enter="createThema"
|
||||
/>
|
||||
<button @click="submit" :disabled="!newTopic.trim()">+</button>
|
||||
<input
|
||||
v-else-if="form.sourceType === 'projekt' || form.sourceType === 'uni'"
|
||||
class="dlg-input" v-model="form.sourceOrt"
|
||||
placeholder="Ordner ab ./ (z.B. projects/meinprojekt)" @keyup.enter="createThema"
|
||||
/>
|
||||
<p class="dlg-hint">{{ sourceHint }}</p>
|
||||
<div class="dlg-actions">
|
||||
<button class="dlg-cancel" @click="dlg = false">Abbrechen</button>
|
||||
<button class="dlg-create" :disabled="!canCreate" @click="createThema">Erstellen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-toggle" v-if="providers.length">
|
||||
<button
|
||||
@@ -853,4 +898,68 @@ function confirmDeleteProject(name) {
|
||||
50% { opacity: 0.65; }
|
||||
}
|
||||
|
||||
/* Erstellen — inline aufklappbar (kein Modal) */
|
||||
.new-topic-toggle {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.new-topic-toggle:hover { background: var(--accent-hover); }
|
||||
.new-topic-toggle.active { background: var(--accent-hover); }
|
||||
.thema-panel {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.75rem;
|
||||
background: var(--panel-soft);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.dlg-input, .dlg-textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
.dlg-input:focus, .dlg-textarea:focus { outline: none; border-color: var(--accent); }
|
||||
.dlg-textarea { resize: vertical; min-height: 3rem; }
|
||||
.dlg-sources { display: flex; gap: 0.4rem; }
|
||||
.dlg-sources button {
|
||||
flex: 1;
|
||||
padding: 0.45rem 0.2rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.dlg-sources button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
.dlg-hint { margin: 0; font-size: 0.75rem; color: var(--text-faint); }
|
||||
.dlg-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 0.3rem; }
|
||||
.dlg-actions button {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.dlg-create { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
.dlg-create:disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
</style>
|
||||
|
||||
1
templates/Prompt/Bausteine-Quelle-Link.md
Normal file
1
templates/Prompt/Bausteine-Quelle-Link.md
Normal file
@@ -0,0 +1 @@
|
||||
Die Quelle wurde von einer Website gecrawlt und liegt als Text-Dateien im Ordner {project} (Seiten als .txt, PDFs als gleichnamige .txt — lies IMMER die .txt). Jede Seiten-Datei beginnt mit einer `QUELLE:`-Zeile (Ursprungs-URL). Verschaffe dir mit Bash (ls/find) einen Überblick und lies die Dateien mit dem Read-Tool. ZIEL: Klausur-Vorbereitung. Erfasse die PRÜFUNGSRELEVANTEN Bausteine: Definitionen, Kernkonzepte, Verfahren/Algorithmen, Formeln, typische Aufgaben. Nur was in den Dateien steht — nichts Erfundenes, kein externes Wissen dazu.
|
||||
@@ -1 +1 @@
|
||||
Das Thema ist das Projekt unter {project}. Verschaffe dir mit Bash (ls/find) einen Überblick und lies README, Doku-Ordner und den relevanten Quellcode mit dem Read-Tool. PDFs liegen als gleichnamige .txt-Dateien vor — lies IMMER die .txt, nie das PDF. Die Bausteine müssen das echte Projekt widerspiegeln, nichts Erfundenes.
|
||||
Das Thema ist das Projekt unter {project}. Verschaffe dir mit Bash (ls/find) einen Überblick und lies README, Doku-Ordner und den relevanten Quellcode mit dem Read-Tool. PDFs liegen als gleichnamige .txt-Dateien vor — lies IMMER die .txt, nie das PDF. ZIEL: das Projekt VERSTEHEN, nicht nachprogrammieren. Erfasse die Bausteine, die erklären, wie es funktioniert: die Features/Funktionen, die Architektur und Komponenten, die wichtigen Abläufe/Flows (z.B. Request→Response, Datenfluss), die zentralen Konzepte und Entscheidungen. Nicht Zeile-für-Zeile-Code, sondern das Verständnis-Gerüst. Die Bausteine müssen das echte Projekt widerspiegeln, nichts Erfundenes.
|
||||
|
||||
1
templates/Prompt/Bausteine-Quelle-Uni.md
Normal file
1
templates/Prompt/Bausteine-Quelle-Uni.md
Normal file
@@ -0,0 +1 @@
|
||||
Das Lernmaterial liegt im Ordner {project} (z.B. Vorlesungsfolien, Skripte, Übungsblätter; PDFs liegen als gleichnamige .txt-Dateien vor — lies IMMER die .txt, nie das PDF). Verschaffe dir mit Bash (ls/find) einen Überblick und lies die Dateien mit dem Read-Tool. ZIEL: Klausur-Vorbereitung. Erfasse die PRÜFUNGSRELEVANTEN Bausteine: Definitionen, Kernkonzepte, Verfahren/Algorithmen, Formeln und Sätze, typische Aufgaben- und Fragetypen. Die Bausteine müssen das echte Material widerspiegeln — nur was darin vorkommt, nichts Erfundenes, kein externes Lehrbuchwissen dazuerfinden.
|
||||
Reference in New Issue
Block a user