udpate
This commit is contained in:
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS guides (
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
topic TEXT NOT NULL,
|
topic TEXT NOT NULL,
|
||||||
format TEXT NOT NULL,
|
format TEXT NOT NULL,
|
||||||
|
instructions TEXT NOT NULL DEFAULT '',
|
||||||
status TEXT NOT NULL DEFAULT 'queued',
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
progress TEXT,
|
progress TEXT,
|
||||||
error_msg TEXT,
|
error_msg TEXT,
|
||||||
@@ -16,11 +17,36 @@ CREATE TABLE IF NOT EXISTS guides (
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_db: aiosqlite.Connection | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> aiosqlite.Connection:
|
||||||
|
global _db
|
||||||
|
if _db is None:
|
||||||
|
_db = await aiosqlite.connect(DB_PATH)
|
||||||
|
_db.row_factory = None
|
||||||
|
return _db
|
||||||
|
|
||||||
|
|
||||||
async def init_db():
|
async def init_db():
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
db = await get_db()
|
||||||
await db.execute(CREATE_TABLE)
|
await db.execute(CREATE_TABLE)
|
||||||
await db.commit()
|
cursor = await db.execute("PRAGMA table_info(guides)")
|
||||||
|
columns = {row[1] for row in await cursor.fetchall()}
|
||||||
|
if "instructions" not in columns:
|
||||||
|
await db.execute("ALTER TABLE guides ADD COLUMN instructions TEXT NOT NULL DEFAULT ''")
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server-Neustart' "
|
||||||
|
"WHERE status IN ('queued', 'generating')"
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def close_db():
|
||||||
|
global _db
|
||||||
|
if _db is not None:
|
||||||
|
await _db.close()
|
||||||
|
_db = None
|
||||||
|
|
||||||
|
|
||||||
def _row_to_dict(row, cursor):
|
def _row_to_dict(row, cursor):
|
||||||
@@ -29,42 +55,42 @@ def _row_to_dict(row, cursor):
|
|||||||
|
|
||||||
|
|
||||||
async def create_guide(guide: dict) -> dict:
|
async def create_guide(guide: dict) -> dict:
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
db = await get_db()
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""INSERT INTO guides (id, topic, format, status, progress, html_path, pdf_path, created_at, updated_at)
|
"""INSERT INTO guides (id, topic, format, instructions, status, progress, html_path, pdf_path, created_at, updated_at)
|
||||||
VALUES (:id, :topic, :format, :status, :progress, :html_path, :pdf_path, :created_at, :updated_at)""",
|
VALUES (:id, :topic, :format, :instructions, :status, :progress, :html_path, :pdf_path, :created_at, :updated_at)""",
|
||||||
guide,
|
guide,
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return guide
|
return guide
|
||||||
|
|
||||||
|
|
||||||
async def get_guide(guide_id: str) -> dict | None:
|
async def get_guide(guide_id: str) -> dict | None:
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
db = await get_db()
|
||||||
cursor = await db.execute("SELECT * FROM guides WHERE id = ?", (guide_id,))
|
cursor = await db.execute("SELECT * FROM guides WHERE id = ?", (guide_id,))
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
return _row_to_dict(row, cursor)
|
return _row_to_dict(row, cursor)
|
||||||
|
|
||||||
|
|
||||||
async def list_guides() -> list[dict]:
|
async def list_guides() -> list[dict]:
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
db = await get_db()
|
||||||
cursor = await db.execute("SELECT * FROM guides ORDER BY created_at DESC")
|
cursor = await db.execute("SELECT * FROM guides ORDER BY created_at DESC")
|
||||||
rows = await cursor.fetchall()
|
rows = await cursor.fetchall()
|
||||||
return [_row_to_dict(row, cursor) for row in rows]
|
return [_row_to_dict(row, cursor) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def update_guide(guide_id: str, **fields) -> None:
|
async def update_guide(guide_id: str, **fields) -> None:
|
||||||
sets = ", ".join(f"{k} = :{k}" for k in fields)
|
sets = ", ".join(f"{k} = :{k}" for k in fields)
|
||||||
fields["id"] = guide_id
|
fields["id"] = guide_id
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
db = await get_db()
|
||||||
await db.execute(f"UPDATE guides SET {sets} WHERE id = :id", fields)
|
await db.execute(f"UPDATE guides SET {sets} WHERE id = :id", fields)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
async def delete_guide(guide_id: str) -> bool:
|
async def delete_guide(guide_id: str) -> bool:
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
db = await get_db()
|
||||||
cursor = await db.execute("DELETE FROM guides WHERE id = ?", (guide_id,))
|
cursor = await db.execute("DELETE FROM guides WHERE id = ?", (guide_id,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|||||||
@@ -16,16 +16,17 @@ from database import update_guide
|
|||||||
|
|
||||||
_semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS)
|
_semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS)
|
||||||
_active_processes: dict[str, asyncio.subprocess.Process] = {}
|
_active_processes: dict[str, asyncio.subprocess.Process] = {}
|
||||||
|
_cancelled: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
async def cancel_guide(guide_id: str) -> bool:
|
async def cancel_guide(guide_id: str) -> bool:
|
||||||
|
_cancelled.add(guide_id)
|
||||||
process = _active_processes.get(guide_id)
|
process = _active_processes.get(guide_id)
|
||||||
if process and process.returncode is None:
|
if process and process.returncode is None:
|
||||||
process.kill()
|
process.kill()
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
await update_guide(guide_id, status="error", progress=None, error_msg="Abgebrochen", updated_at=now)
|
await update_guide(guide_id, status="error", progress=None, error_msg="Abgebrochen", updated_at=now)
|
||||||
return True
|
return True
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def _set_progress(guide_id: str, progress: str) -> None:
|
async def _set_progress(guide_id: str, progress: str) -> None:
|
||||||
@@ -70,7 +71,7 @@ async def _render_pngs(pdf_path: Path, preview_dir: Path) -> list[Path]:
|
|||||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"python3", "-c",
|
"python3", "-c",
|
||||||
f"from pdf2image import convert_from_path; pages = convert_from_path('{pdf_path}', dpi=120); [p.save('{preview_dir}/page_{{i}}.png') for i, p in enumerate(pages)]; print(len(pages))",
|
f"from pdf2image import convert_from_path; pages = convert_from_path('{pdf_path}', dpi=120); [p.save('{preview_dir}/page_' + str(i) + '.png') for i, p in enumerate(pages)]; print(len(pages))",
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
@@ -79,10 +80,12 @@ async def _render_pngs(pdf_path: Path, preview_dir: Path) -> list[Path]:
|
|||||||
return pngs
|
return pngs
|
||||||
|
|
||||||
|
|
||||||
def _build_generator_prompt(topic: str, format_name: str, html_path: Path) -> str:
|
def _build_generator_prompt(topic: str, format_name: str, html_path: Path, instructions: str = "") -> str:
|
||||||
spec = (TEMPLATES_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8")
|
spec = (TEMPLATES_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8")
|
||||||
reference = (TEMPLATES_DIR / "Referenz" / f"{format_name}.md").read_text(encoding="utf-8")
|
reference = (TEMPLATES_DIR / "Referenz" / f"{format_name}.md").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
extra = f"\n\nZUSÄTZLICHE ANWEISUNGEN VOM NUTZER:\n{instructions}\n" if instructions else ""
|
||||||
|
|
||||||
return f"""Erstelle einen Lern-Guide zum Thema "{topic}" im Format "{format_name}".
|
return f"""Erstelle einen Lern-Guide zum Thema "{topic}" im Format "{format_name}".
|
||||||
|
|
||||||
Recherchiere zuerst die aktuelle Version und aktuelle Fakten zu "{topic}" per Websuche, damit Versionsnummern und Angaben stimmen.
|
Recherchiere zuerst die aktuelle Version und aktuelle Fakten zu "{topic}" per Websuche, damit Versionsnummern und Angaben stimmen.
|
||||||
@@ -96,6 +99,23 @@ FORMAT-SPEZIFIKATION:
|
|||||||
|
|
||||||
REFERENZ-IMPLEMENTIERUNG (Stil-Vorlage, adaptiere für "{topic}"):
|
REFERENZ-IMPLEMENTIERUNG (Stil-Vorlage, adaptiere für "{topic}"):
|
||||||
{reference}
|
{reference}
|
||||||
|
{extra}"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_rework_prompt(topic: str, format_name: str, html_path: Path, instructions: str) -> str:
|
||||||
|
spec = (TEMPLATES_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8")
|
||||||
|
return f"""Überarbeite die bestehende HTML-Datei {html_path} für den "{format_name}" zum Thema "{topic}".
|
||||||
|
|
||||||
|
Lies zuerst die aktuelle HTML-Datei mit dem Read-Tool.
|
||||||
|
|
||||||
|
ANWEISUNGEN VOM NUTZER:
|
||||||
|
{instructions}
|
||||||
|
|
||||||
|
FORMAT-SPEZIFIKATION (muss weiterhin eingehalten werden):
|
||||||
|
{spec}
|
||||||
|
|
||||||
|
Schreibe die überarbeitete Version in dieselbe Datei: {html_path}
|
||||||
|
Führe KEIN weasyprint aus, erzeuge KEINE PDF.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -113,24 +133,28 @@ Führe KEIN weasyprint aus, erzeuge KEINE PDF.
|
|||||||
def _build_review_prompt(format_name: str, png_paths: list[Path], page_count: int) -> str:
|
def _build_review_prompt(format_name: str, png_paths: list[Path], page_count: int) -> str:
|
||||||
spec = (TEMPLATES_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8")
|
spec = (TEMPLATES_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8")
|
||||||
|
|
||||||
png_list = "\n".join(str(p) for p in png_paths)
|
read_instructions = "\n".join(
|
||||||
|
f"- Öffne mit dem Read-Tool: {p}" for p in png_paths
|
||||||
|
)
|
||||||
|
|
||||||
return f"""Prüfe die folgenden Preview-Bilder eines generierten "{format_name}" Guides.
|
return f"""Prüfe visuell einen generierten "{format_name}" Guide.
|
||||||
|
|
||||||
Das PDF hat {page_count} Seite(n). Lies die Preview-Bilder und prüfe sie:
|
SCHRITT 1 — Bilder laden:
|
||||||
{png_list}
|
Das PDF hat {page_count} Seite(n), gerendert als PNG-Screenshots.
|
||||||
|
Nutze das Read-Tool, um JEDE der folgenden Dateien zu öffnen und visuell zu inspizieren:
|
||||||
|
{read_instructions}
|
||||||
|
|
||||||
FORMAT-SPEZIFIKATION (Prüfkriterien):
|
SCHRITT 2 — Visuell prüfen anhand dieser Spezifikation:
|
||||||
{spec}
|
{spec}
|
||||||
|
|
||||||
Prüfe anhand der Spezifikation:
|
Prüfkriterien (basierend auf dem, was du in den Bildern SIEHST):
|
||||||
- Stimmt die Seitenanzahl? (OnePager/Cheatsheet = exakt 1 Seite)
|
- Stimmt die Seitenanzahl? (OnePager/Cheatsheet = exakt 1 Seite)
|
||||||
- Sind Elemente abgeschnitten oder überlappend?
|
- Ist Text abgeschnitten, überlappt oder läuft aus dem sichtbaren Bereich?
|
||||||
- Fehlen Pflicht-Elemente (Cover, TOC, Recall-Boxen, Callouts, etc.)?
|
- Fehlen Pflicht-Elemente (Cover, TOC, Recall-Boxen, Callouts, etc.)?
|
||||||
- Sind Code-Blöcke über Seitenumbrüche zerrissen?
|
- Sind Code-Blöcke über Seitenumbrüche zerrissen?
|
||||||
- Ist das Layout korrekt (Spalten, Grid, Footer)?
|
- Ist das Layout korrekt (Spalten, Grid, Footer)?
|
||||||
|
|
||||||
Antworte mit GENAU EINEM der folgenden Formate:
|
SCHRITT 3 — Antworte mit GENAU EINEM der folgenden Formate:
|
||||||
|
|
||||||
Bei Bestehen:
|
Bei Bestehen:
|
||||||
PASS
|
PASS
|
||||||
@@ -143,7 +167,7 @@ FAIL
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def generate_guide(guide_id: str, topic: str, format_name: str) -> None:
|
async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "") -> None:
|
||||||
async with _semaphore:
|
async with _semaphore:
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
await update_guide(guide_id, status="generating", progress="Recherche…", updated_at=now)
|
await update_guide(guide_id, status="generating", progress="Recherche…", updated_at=now)
|
||||||
@@ -155,11 +179,16 @@ async def generate_guide(guide_id: str, topic: str, format_name: str) -> None:
|
|||||||
max_iter = MAX_ITERATIONS.get(format_name, 3)
|
max_iter = MAX_ITERATIONS.get(format_name, 3)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if guide_id in _cancelled:
|
||||||
|
return
|
||||||
|
|
||||||
# Step 1: Generator-Agent erstellt HTML
|
# Step 1: Generator-Agent erstellt HTML
|
||||||
await _set_progress(guide_id, "Generiere HTML…")
|
await _set_progress(guide_id, "Generiere HTML…")
|
||||||
gen_prompt = _build_generator_prompt(topic, format_name, html_path)
|
gen_prompt = _build_generator_prompt(topic, format_name, html_path, instructions)
|
||||||
returncode, stdout, stderr = await _run_claude(guide_id, gen_prompt, timeout)
|
returncode, stdout, stderr = await _run_claude(guide_id, gen_prompt, timeout)
|
||||||
|
|
||||||
|
if guide_id in _cancelled:
|
||||||
|
return
|
||||||
if returncode != 0:
|
if returncode != 0:
|
||||||
await _fail(guide_id, f"Generator-Fehler: {stderr[:1000]}")
|
await _fail(guide_id, f"Generator-Fehler: {stderr[:1000]}")
|
||||||
return
|
return
|
||||||
@@ -170,6 +199,9 @@ async def generate_guide(guide_id: str, topic: str, format_name: str) -> None:
|
|||||||
|
|
||||||
# Step 2-N: Render → Review → Fix Loop
|
# Step 2-N: Render → Review → Fix Loop
|
||||||
for iteration in range(1, max_iter + 1):
|
for iteration in range(1, max_iter + 1):
|
||||||
|
if guide_id in _cancelled:
|
||||||
|
return
|
||||||
|
|
||||||
await _set_progress(guide_id, f"Rendere PDF… (Iteration {iteration})")
|
await _set_progress(guide_id, f"Rendere PDF… (Iteration {iteration})")
|
||||||
ok, err = await _render_pdf(html_path, pdf_path)
|
ok, err = await _render_pdf(html_path, pdf_path)
|
||||||
if not ok:
|
if not ok:
|
||||||
@@ -222,6 +254,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str) -> None:
|
|||||||
await _fail(guide_id, str(e)[:2000])
|
await _fail(guide_id, str(e)[:2000])
|
||||||
finally:
|
finally:
|
||||||
_active_processes.pop(guide_id, None)
|
_active_processes.pop(guide_id, None)
|
||||||
|
_cancelled.discard(guide_id)
|
||||||
# Preview-PNGs aufräumen
|
# Preview-PNGs aufräumen
|
||||||
if preview_dir.exists():
|
if preview_dir.exists():
|
||||||
for f in preview_dir.glob("*"):
|
for f in preview_dir.glob("*"):
|
||||||
@@ -229,6 +262,88 @@ async def generate_guide(guide_id: str, topic: str, format_name: str) -> None:
|
|||||||
preview_dir.rmdir()
|
preview_dir.rmdir()
|
||||||
|
|
||||||
|
|
||||||
|
async def rework_guide(guide_id: str, topic: str, format_name: str, instructions: str) -> None:
|
||||||
|
async with _semaphore:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
await update_guide(guide_id, status="generating", progress="Überarbeite…", updated_at=now)
|
||||||
|
|
||||||
|
html_path = STORAGE_DIR / "html" / f"{guide_id}.html"
|
||||||
|
pdf_path = STORAGE_DIR / "pdf" / f"{guide_id}.pdf"
|
||||||
|
preview_dir = STORAGE_DIR / "preview" / guide_id
|
||||||
|
timeout = GENERATION_TIMEOUTS.get(format_name, 600)
|
||||||
|
max_iter = MAX_ITERATIONS.get(format_name, 3)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if guide_id in _cancelled:
|
||||||
|
return
|
||||||
|
|
||||||
|
rework_prompt = _build_rework_prompt(topic, format_name, html_path, instructions)
|
||||||
|
returncode, stdout, stderr = await _run_claude(guide_id, rework_prompt, timeout)
|
||||||
|
|
||||||
|
if guide_id in _cancelled:
|
||||||
|
return
|
||||||
|
if returncode != 0:
|
||||||
|
await _fail(guide_id, f"Rework-Fehler: {stderr[:1000]}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not html_path.exists():
|
||||||
|
await _fail(guide_id, "HTML-Datei wurde nicht erstellt")
|
||||||
|
return
|
||||||
|
|
||||||
|
for iteration in range(1, max_iter + 1):
|
||||||
|
if guide_id in _cancelled:
|
||||||
|
return
|
||||||
|
|
||||||
|
await _set_progress(guide_id, f"Rendere PDF… (Iteration {iteration})")
|
||||||
|
ok, err = await _render_pdf(html_path, pdf_path)
|
||||||
|
if not ok:
|
||||||
|
await _fail(guide_id, f"WeasyPrint-Fehler: {err}")
|
||||||
|
return
|
||||||
|
|
||||||
|
await _set_progress(guide_id, f"Prüfe… (Iteration {iteration})")
|
||||||
|
pngs = await _render_pngs(pdf_path, preview_dir)
|
||||||
|
page_count = len(pngs)
|
||||||
|
|
||||||
|
review_prompt = _build_review_prompt(format_name, pngs, page_count)
|
||||||
|
returncode, review_out, review_err = await _run_claude(guide_id, review_prompt, 120)
|
||||||
|
|
||||||
|
if returncode != 0:
|
||||||
|
await _fail(guide_id, f"Review-Fehler: {review_err[:1000]}")
|
||||||
|
return
|
||||||
|
|
||||||
|
review_text = review_out.strip()
|
||||||
|
if review_text.startswith("PASS"):
|
||||||
|
break
|
||||||
|
if iteration == max_iter:
|
||||||
|
break
|
||||||
|
|
||||||
|
feedback = review_text.replace("FAIL", "").strip()
|
||||||
|
await _set_progress(guide_id, f"Korrigiere… (Iteration {iteration})")
|
||||||
|
fix_prompt = _build_fix_prompt(topic, format_name, html_path, feedback)
|
||||||
|
returncode, _, fix_err = await _run_claude(guide_id, fix_prompt, timeout)
|
||||||
|
if returncode != 0:
|
||||||
|
await _fail(guide_id, f"Fix-Fehler: {fix_err[:1000]}")
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
await update_guide(
|
||||||
|
guide_id, status="done", progress=None,
|
||||||
|
html_path=str(html_path), pdf_path=str(pdf_path), updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
await _fail(guide_id, f"Timeout nach {timeout}s")
|
||||||
|
except Exception as e:
|
||||||
|
await _fail(guide_id, str(e)[:2000])
|
||||||
|
finally:
|
||||||
|
_active_processes.pop(guide_id, None)
|
||||||
|
_cancelled.discard(guide_id)
|
||||||
|
if preview_dir.exists():
|
||||||
|
for f in preview_dir.glob("*"):
|
||||||
|
f.unlink()
|
||||||
|
preview_dir.rmdir()
|
||||||
|
|
||||||
|
|
||||||
async def _fail(guide_id: str, msg: str) -> None:
|
async def _fail(guide_id: str, msg: str) -> None:
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
await update_guide(guide_id, status="error", progress=None, error_msg=msg, updated_at=now)
|
await update_guide(guide_id, status="error", progress=None, error_msg=msg, updated_at=now)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from config import STORAGE_DIR
|
from config import STORAGE_DIR
|
||||||
from database import init_db
|
from database import init_db, close_db
|
||||||
from routes import router
|
from routes import router
|
||||||
|
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ async def lifespan(app: FastAPI):
|
|||||||
(STORAGE_DIR / "preview").mkdir(parents=True, exist_ok=True)
|
(STORAGE_DIR / "preview").mkdir(parents=True, exist_ok=True)
|
||||||
await init_db()
|
await init_db()
|
||||||
yield
|
yield
|
||||||
|
await close_db()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Guides Generator", lifespan=lifespan)
|
app = FastAPI(title="Guides Generator", lifespan=lifespan)
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ FormatType = Literal[
|
|||||||
class GuideCreateRequest(BaseModel):
|
class GuideCreateRequest(BaseModel):
|
||||||
topic: str = Field(min_length=1, max_length=100)
|
topic: str = Field(min_length=1, max_length=100)
|
||||||
format: FormatType
|
format: FormatType
|
||||||
|
instructions: str = Field(default="", max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class GuideReworkRequest(BaseModel):
|
||||||
|
instructions: str = Field(min_length=1, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
class GuideResponse(BaseModel):
|
class GuideResponse(BaseModel):
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ from fastapi.responses import FileResponse
|
|||||||
|
|
||||||
from config import FORMAT_META, STORAGE_DIR
|
from config import FORMAT_META, STORAGE_DIR
|
||||||
from database import create_guide, delete_guide, get_guide, list_guides
|
from database import create_guide, delete_guide, get_guide, list_guides
|
||||||
from generator import generate_guide, cancel_guide
|
from generator import generate_guide, rework_guide, cancel_guide
|
||||||
from models import GuideCreateRequest, GuideResponse
|
from models import GuideCreateRequest, GuideReworkRequest, GuideResponse
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ async def create(req: GuideCreateRequest):
|
|||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"topic": req.topic.strip(),
|
"topic": req.topic.strip(),
|
||||||
"format": req.format,
|
"format": req.format,
|
||||||
|
"instructions": req.instructions.strip(),
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"progress": None,
|
"progress": None,
|
||||||
"html_path": None,
|
"html_path": None,
|
||||||
@@ -34,7 +35,7 @@ async def create(req: GuideCreateRequest):
|
|||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}
|
}
|
||||||
await create_guide(guide)
|
await create_guide(guide)
|
||||||
asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"]))
|
asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"]))
|
||||||
return guide
|
return guide
|
||||||
|
|
||||||
|
|
||||||
@@ -64,6 +65,17 @@ async def download_html(guide_id: str):
|
|||||||
return FileResponse(path, filename=f"{guide['topic']}-{guide['format']}.html", media_type="text/html")
|
return FileResponse(path, filename=f"{guide['topic']}-{guide['format']}.html", media_type="text/html")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/guides/{guide_id}/rework")
|
||||||
|
async def rework(guide_id: str, req: GuideReworkRequest):
|
||||||
|
guide = await get_guide(guide_id)
|
||||||
|
if guide is None:
|
||||||
|
raise HTTPException(404, "Guide nicht gefunden")
|
||||||
|
if guide["status"] != "done":
|
||||||
|
raise HTTPException(400, "Guide muss fertig sein")
|
||||||
|
asyncio.create_task(rework_guide(guide_id, guide["topic"], guide["format"], req.instructions.strip()))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/guides/{guide_id}/cancel")
|
@router.post("/guides/{guide_id}/cancel")
|
||||||
async def cancel(guide_id: str):
|
async def cancel(guide_id: str):
|
||||||
cancelled = await cancel_guide(guide_id)
|
cancelled = await cancel_guide(guide_id)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { fetchGuides, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel } from './api.js'
|
import { fetchGuides, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, reworkGuide as apiRework } from './api.js'
|
||||||
import TopicSidebar from './components/TopicSidebar.vue'
|
import TopicSidebar from './components/TopicSidebar.vue'
|
||||||
import TopicDetail from './components/TopicDetail.vue'
|
import TopicDetail from './components/TopicDetail.vue'
|
||||||
|
|
||||||
@@ -73,9 +73,15 @@ function createTopic(topic) {
|
|||||||
previewGuide.value = null
|
previewGuide.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFormatClick(format) {
|
async function handleFormatClick({ format, instructions }) {
|
||||||
if (!selectedTopic.value) return
|
if (!selectedTopic.value) return
|
||||||
await apiCreate(selectedTopic.value, format)
|
await apiCreate(selectedTopic.value, format, instructions)
|
||||||
|
await loadGuides()
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRework({ guideId, instructions }) {
|
||||||
|
await apiRework(guideId, instructions)
|
||||||
await loadGuides()
|
await loadGuides()
|
||||||
startPolling()
|
startPolling()
|
||||||
}
|
}
|
||||||
@@ -162,6 +168,7 @@ onUnmounted(() => {
|
|||||||
@cancelGuide="handleCancel"
|
@cancelGuide="handleCancel"
|
||||||
@deleteGuide="handleDeleteGuide"
|
@deleteGuide="handleDeleteGuide"
|
||||||
@preview="handlePreview"
|
@preview="handlePreview"
|
||||||
|
@rework="handleRework"
|
||||||
/>
|
/>
|
||||||
<TopicDetail
|
<TopicDetail
|
||||||
v-if="selectedTopic"
|
v-if="selectedTopic"
|
||||||
|
|||||||
@@ -10,11 +10,20 @@ export async function fetchGuide(id) {
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createGuide(topic, format) {
|
export async function createGuide(topic, format, instructions = '') {
|
||||||
const res = await fetch(`${BASE}/guides`, {
|
const res = await fetch(`${BASE}/guides`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ topic, format }),
|
body: JSON.stringify({ topic, format, instructions }),
|
||||||
|
})
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reworkGuide(id, instructions) {
|
||||||
|
const res = await fetch(`${BASE}/guides/${id}/rework`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ instructions }),
|
||||||
})
|
})
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const props = defineProps({
|
|||||||
allGuides: { type: Array, default: () => [] },
|
allGuides: { type: Array, default: () => [] },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['select', 'create', 'formatClick', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'preview'])
|
const emit = defineEmits(['select', 'create', 'formatClick', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'preview', 'rework'])
|
||||||
|
|
||||||
const formats = [
|
const formats = [
|
||||||
{ key: 'OnePager', label: 'OnePager' },
|
{ key: 'OnePager', label: 'OnePager' },
|
||||||
@@ -38,12 +38,33 @@ function handleFormatClick(format) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePlay(format) {
|
const activeInput = ref(null)
|
||||||
const guide = props.guidesByFormat[format]
|
const inputText = ref('')
|
||||||
if (guide?.status === 'done') {
|
|
||||||
if (!confirm('Guide überschreiben?')) return
|
function toggleInput(format) {
|
||||||
|
if (activeInput.value === format) {
|
||||||
|
activeInput.value = null
|
||||||
|
inputText.value = ''
|
||||||
|
} else {
|
||||||
|
activeInput.value = format
|
||||||
|
inputText.value = ''
|
||||||
}
|
}
|
||||||
emit('formatClick', format)
|
}
|
||||||
|
|
||||||
|
function handlePlay(format) {
|
||||||
|
const text = activeInput.value === format ? inputText.value.trim() : ''
|
||||||
|
emit('formatClick', { format, instructions: text })
|
||||||
|
activeInput.value = null
|
||||||
|
inputText.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRefresh(format) {
|
||||||
|
const guide = props.guidesByFormat[format]
|
||||||
|
if (!guide) return
|
||||||
|
const text = activeInput.value === format ? inputText.value.trim() : ''
|
||||||
|
emit('rework', { guideId: guide.id, instructions: text || 'Überarbeite das Layout' })
|
||||||
|
activeInput.value = null
|
||||||
|
inputText.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete(format) {
|
function handleDelete(format) {
|
||||||
@@ -94,31 +115,44 @@ function submit() {
|
|||||||
<div class="progress-info" v-if="activeGenerations.length">
|
<div class="progress-info" v-if="activeGenerations.length">
|
||||||
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
|
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-for="f in formats" :key="f.key">
|
||||||
v-for="f in formats"
|
<div :class="['format-row', 'fmt-' + guideStatus(f.key)]">
|
||||||
:key="f.key"
|
<button class="format-name" @click="handleFormatClick(f.key)">
|
||||||
:class="['format-row', 'fmt-' + guideStatus(f.key)]"
|
<span class="format-label">{{ f.label }}</span>
|
||||||
>
|
<span
|
||||||
<button class="format-name" @click="handleFormatClick(f.key)">
|
v-if="guideStatus(f.key) !== 'none'"
|
||||||
{{ f.label }}
|
class="format-x"
|
||||||
</button>
|
@click.stop="handleDelete(f.key)"
|
||||||
<div class="format-actions">
|
:title="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued' ? 'Abbrechen' : 'Löschen'"
|
||||||
<button
|
>×</span>
|
||||||
v-if="guideStatus(f.key) !== 'generating' && guideStatus(f.key) !== 'queued'"
|
|
||||||
class="action-btn play"
|
|
||||||
:title="guideStatus(f.key) === 'done' ? 'Neu generieren' : 'Generieren'"
|
|
||||||
@click="handlePlay(f.key)"
|
|
||||||
>
|
|
||||||
{{ guideStatus(f.key) === 'done' ? '↻' : '▶' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="guideStatus(f.key) !== 'none'"
|
|
||||||
class="action-btn delete"
|
|
||||||
:title="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued' ? 'Abbrechen' : 'Löschen'"
|
|
||||||
@click="handleDelete(f.key)"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
</button>
|
||||||
|
<div class="format-actions">
|
||||||
|
<template v-if="guideStatus(f.key) === 'done'">
|
||||||
|
<button class="action-btn refresh" title="Überarbeiten" @click="handleRefresh(f.key)">↻</button>
|
||||||
|
<button
|
||||||
|
class="action-btn pencil"
|
||||||
|
:class="{ active: activeInput === f.key }"
|
||||||
|
title="Anweisungen"
|
||||||
|
@click="toggleInput(f.key)"
|
||||||
|
>✎</button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="guideStatus(f.key) !== 'generating' && guideStatus(f.key) !== 'queued'">
|
||||||
|
<button class="action-btn play" title="Generieren" @click="handlePlay(f.key)">▶</button>
|
||||||
|
<button
|
||||||
|
class="action-btn pencil"
|
||||||
|
:class="{ active: activeInput === f.key }"
|
||||||
|
title="Anweisungen"
|
||||||
|
@click="toggleInput(f.key)"
|
||||||
|
>✎</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="activeInput === f.key" class="format-input">
|
||||||
|
<input
|
||||||
|
v-model="inputText"
|
||||||
|
:placeholder="guideStatus(f.key) === 'done' ? 'Was soll überarbeitet werden?' : 'Anweisungen (optional)…'"
|
||||||
|
@keyup.enter="guideStatus(f.key) === 'done' ? handleRefresh(f.key) : handlePlay(f.key)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,6 +286,22 @@ function submit() {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
color: #999;
|
color: #999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-x {
|
||||||
|
display: none;
|
||||||
|
color: #991b1b;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-name:hover .format-x {
|
||||||
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fmt-done .format-name {
|
.fmt-done .format-name {
|
||||||
@@ -309,14 +359,40 @@ function submit() {
|
|||||||
border-color: #34d399;
|
border-color: #34d399;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn.delete {
|
.action-btn.refresh {
|
||||||
color: #991b1b;
|
color: #059669;
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn.delete:hover {
|
.action-btn.refresh:hover {
|
||||||
background: #fee2e2;
|
background: #d1fae5;
|
||||||
border-color: #f87171;
|
border-color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.pencil {
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.pencil:hover,
|
||||||
|
.action-btn.pencil.active {
|
||||||
|
background: #ede9fe;
|
||||||
|
border-color: #a5b4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-input {
|
||||||
|
padding: 4px 0.75rem 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-input input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #d8dde3;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-input input:focus {
|
||||||
|
border-color: #6366f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
|
|||||||
Reference in New Issue
Block a user