update
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
|
||||
import aiosqlite
|
||||
from config import DB_PATH
|
||||
|
||||
@@ -32,6 +34,19 @@ CREATE TABLE IF NOT EXISTS topics (
|
||||
)
|
||||
"""
|
||||
|
||||
CREATE_ELEMENTS = """
|
||||
CREATE TABLE IF NOT EXISTS elements (
|
||||
id TEXT PRIMARY KEY,
|
||||
topic TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
examples TEXT NOT NULL DEFAULT '[]',
|
||||
hints TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
|
||||
_db: aiosqlite.Connection | None = None
|
||||
|
||||
|
||||
@@ -48,6 +63,7 @@ async def init_db():
|
||||
await db.execute(CREATE_GUIDES)
|
||||
await db.execute(CREATE_PROGRESS)
|
||||
await db.execute(CREATE_TOPICS)
|
||||
await db.execute(CREATE_ELEMENTS)
|
||||
try: # Migration für Bestands-DBs ohne step-Spalte
|
||||
await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER")
|
||||
except aiosqlite.OperationalError:
|
||||
@@ -138,6 +154,63 @@ async def delete_topic(name: str) -> None:
|
||||
await db.commit()
|
||||
|
||||
|
||||
# --- Elemente ---
|
||||
|
||||
def _element_row(row, cursor) -> dict:
|
||||
el = _row_to_dict(row, cursor)
|
||||
el["examples"] = json.loads(el["examples"] or "[]")
|
||||
el["hints"] = json.loads(el["hints"] or "[]")
|
||||
return el
|
||||
|
||||
|
||||
async def create_element(element: dict) -> dict:
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO elements (id, topic, title, description, examples, hints, created_at, updated_at)
|
||||
VALUES (:id, :topic, :title, :description, :examples, :hints, :created_at, :updated_at)""",
|
||||
{**element, "examples": json.dumps(element["examples"], ensure_ascii=False),
|
||||
"hints": json.dumps(element["hints"], ensure_ascii=False)},
|
||||
)
|
||||
await db.commit()
|
||||
return element
|
||||
|
||||
|
||||
async def list_elements(topic: str) -> list[dict]:
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM elements WHERE topic = ? ORDER BY updated_at DESC", (topic,)
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [_element_row(row, cursor) for row in rows]
|
||||
|
||||
|
||||
async def get_element(element_id: str) -> dict | None:
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM elements WHERE id = ?", (element_id,))
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _element_row(row, cursor)
|
||||
|
||||
|
||||
async def update_element(element_id: str, **fields) -> None:
|
||||
for key in ("examples", "hints"):
|
||||
if key in fields:
|
||||
fields[key] = json.dumps(fields[key], ensure_ascii=False)
|
||||
sets = ", ".join(f"{k} = :{k}" for k in fields)
|
||||
fields["id"] = element_id
|
||||
db = await get_db()
|
||||
await db.execute(f"UPDATE elements SET {sets} WHERE id = :id", fields)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def delete_element(element_id: str) -> bool:
|
||||
db = await get_db()
|
||||
cursor = await db.execute("DELETE FROM elements WHERE id = ?", (element_id,))
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
# --- Kapitel-Fortschritt ---
|
||||
|
||||
async def list_progress(guide_id: str) -> list[str]:
|
||||
|
||||
@@ -1350,3 +1350,232 @@ async def chat_with_guide(topic: str, format_name: str, section: str, outline: s
|
||||
return reply or "Entschuldigung, ich habe keine Antwort erhalten."
|
||||
except Exception:
|
||||
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
|
||||
|
||||
|
||||
# --- Elemente (persönliche Zusammenfassung) ---
|
||||
|
||||
def _parse_json_text(text: str):
|
||||
"""Parst JSON aus KI-Output (Code-Fences und Drumherum-Text tolerant).
|
||||
|
||||
Repariert unescapte Anführungszeichen in Strings (z. B. MiniMax: "Titel „p" geändert"):
|
||||
das letzte `"` vor der Fehlerstelle escapen und erneut parsen.
|
||||
"""
|
||||
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", (text or "").strip())
|
||||
start, end = text.find("{"), text.rfind("}")
|
||||
if start == -1 or end <= start:
|
||||
return None
|
||||
candidate = text[start:end + 1]
|
||||
for _ in range(20):
|
||||
try:
|
||||
return json.loads(candidate)
|
||||
except json.JSONDecodeError as e:
|
||||
if not e.msg.startswith(("Expecting ',' delimiter", "Expecting ':' delimiter")):
|
||||
return None
|
||||
q = candidate.rfind('"', 0, e.pos)
|
||||
if q <= 0:
|
||||
return None
|
||||
candidate = candidate[:q] + '\\"' + candidate[q + 1:]
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _element_fields(data: dict) -> dict | None:
|
||||
"""Validiert KI-Element-JSON und normalisiert auf die DB-Felder."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
title = str(data.get("title", "")).strip()
|
||||
if not title:
|
||||
return None
|
||||
listen = {}
|
||||
for key in ("examples", "hints"):
|
||||
raw = data.get(key, [])
|
||||
listen[key] = [str(e).strip() for e in raw if str(e).strip()] if isinstance(raw, list) else []
|
||||
return {
|
||||
"title": title[:200],
|
||||
"description": str(data.get("description", "")).strip(),
|
||||
"examples": listen["examples"][:5],
|
||||
"hints": listen["hints"][:5],
|
||||
}
|
||||
|
||||
|
||||
def _topic_context(topic: str, limit: int = 12000) -> str:
|
||||
"""Bausteine + Guide-Inhalte des Themas als Kontext-Text (gekürzt)."""
|
||||
parts: list[str] = []
|
||||
bp = bausteine_path(topic)
|
||||
if bp.exists():
|
||||
parts.append(bp.read_text(encoding="utf-8"))
|
||||
for fmt in ("FullGuide", "Guide", "MiniGuide", "OnePager"):
|
||||
content = _json_datei(guide_content_path(topic, fmt))
|
||||
if content:
|
||||
for ch in content.get("chapters", []):
|
||||
for sec in ch.get("sections", []):
|
||||
parts.append(sec if isinstance(sec, str) else json.dumps(sec, ensure_ascii=False))
|
||||
break # bester verfügbarer Guide reicht
|
||||
text = "\n\n".join(parts).strip()
|
||||
return text[:limit] if text else "(kein Material vorhanden)"
|
||||
|
||||
|
||||
async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVIDER) -> dict:
|
||||
"""Erstellt Element-Felder per KI. Fallback: nur Titel aus dem Stichwort."""
|
||||
fallback = {"title": hint.strip() or "Neues Element", "description": "", "examples": [], "hints": []}
|
||||
try:
|
||||
prompt = _prompt(
|
||||
"Element-Create",
|
||||
topic=topic, hint=hint.strip() or "(keins — wähle selbst ein Kernkonzept)",
|
||||
context=_topic_context(topic),
|
||||
)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"element-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
|
||||
)
|
||||
if returncode != 0:
|
||||
return fallback
|
||||
return _element_fields(_parse_json_text(stdout)) or fallback
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
def _fence(content: str) -> str:
|
||||
"""Beispiele müssen Codeblöcke sein — fehlende Fences nachrüsten."""
|
||||
if content.startswith("```"):
|
||||
return content
|
||||
return f"```\n{content}\n```"
|
||||
|
||||
|
||||
def _parse_suggestions(stdout: str) -> list[dict] | None:
|
||||
"""Validiert Vorschlags-JSON aus KI-Output. None bei ungültigem JSON."""
|
||||
data = _parse_json_text(stdout)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
suggestions = []
|
||||
for s in data.get("suggestions", []):
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
text = str(s.get("text", "")).strip()
|
||||
target = s.get("target")
|
||||
content = str(s.get("content", "")).strip()
|
||||
if text and content and target in ("description", "examples", "hints"):
|
||||
if target == "examples":
|
||||
content = _fence(content)
|
||||
suggestions.append({"text": text, "target": target, "content": content})
|
||||
return suggestions
|
||||
|
||||
|
||||
async def check_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None:
|
||||
"""Zweischrittige Prüfung auf fehlende Infos: Recherche → Verifizieren. None bei Fehler."""
|
||||
try:
|
||||
element_json = json.dumps(
|
||||
{k: element[k] for k in ("title", "description", "examples", "hints")},
|
||||
ensure_ascii=False, indent=1,
|
||||
)
|
||||
context = _topic_context(element["topic"])
|
||||
|
||||
# Schritt 1: Recherche — breit Kandidaten sammeln
|
||||
prompt = _prompt("Element-Check", topic=element["topic"], element_json=element_json, context=context)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"element-check-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
|
||||
)
|
||||
if returncode != 0:
|
||||
return None
|
||||
candidates = _parse_suggestions(stdout)
|
||||
if candidates is None:
|
||||
return None
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
# Schritt 2: Verifizieren — nur Wichtiges, nicht Redundantes durchlassen
|
||||
prompt = _prompt(
|
||||
"Element-Verify",
|
||||
topic=element["topic"], element_json=element_json,
|
||||
candidates_json=json.dumps({"suggestions": candidates}, ensure_ascii=False, indent=1),
|
||||
context=context,
|
||||
)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"element-verify-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
|
||||
)
|
||||
if returncode != 0:
|
||||
return None
|
||||
return _parse_suggestions(stdout)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _element_rewrite(template: str, element: dict, provider: str, **extra) -> tuple[str, dict | None]:
|
||||
"""Gemeinsames Muster: Element + Template → (Antwort, neue Felder|None)."""
|
||||
fehler = "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
|
||||
try:
|
||||
element_json = json.dumps(
|
||||
{k: element[k] for k in ("title", "description", "examples", "hints")},
|
||||
ensure_ascii=False, indent=1,
|
||||
)
|
||||
prompt = _prompt(template, topic=element["topic"], element_json=element_json, **extra)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
f"element-{template.lower()}-" + str(uuid.uuid4()), prompt, 240,
|
||||
provider=provider, role="fast", capabilities="none",
|
||||
)
|
||||
if returncode != 0:
|
||||
return fehler, None
|
||||
data = _parse_json_text(stdout)
|
||||
if not isinstance(data, dict):
|
||||
return fehler, None
|
||||
fields = _element_fields(data.get("element"))
|
||||
reply = str(data.get("reply", "")).strip() or ("Erledigt." if fields else fehler)
|
||||
return reply, fields
|
||||
except Exception:
|
||||
return fehler, None
|
||||
|
||||
|
||||
async def chat_with_element(element: dict, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> tuple[str, dict | None]:
|
||||
"""Passt ein Element per Chat an. Gibt (Antwort, neue Felder|None) zurück."""
|
||||
transcript = "\n".join(
|
||||
f"{'Nutzer' if m.get('role') == 'user' else 'Assistent'}: {m.get('content', '')}"
|
||||
for m in messages
|
||||
)
|
||||
return await _element_rewrite("Element-Chat", element, provider, transcript=transcript)
|
||||
|
||||
|
||||
async def style_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None:
|
||||
"""Prüft ein Element auf die Stil-Regeln und schlägt Änderungen vor. None bei Fehler."""
|
||||
try:
|
||||
element_json = json.dumps(
|
||||
{k: element[k] for k in ("title", "description", "examples", "hints")},
|
||||
ensure_ascii=False, indent=1,
|
||||
)
|
||||
prompt = _prompt("Element-Stil", topic=element["topic"], element_json=element_json)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"element-stil-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
|
||||
)
|
||||
if returncode != 0:
|
||||
return None
|
||||
data = _parse_json_text(stdout)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
changes = []
|
||||
for c in data.get("changes", []):
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
text = str(c.get("text", "")).strip()
|
||||
action = c.get("action")
|
||||
target = c.get("target")
|
||||
index = c.get("index")
|
||||
content = str(c.get("content", "")).strip()
|
||||
if not text or action not in ("entfernen", "anpassen", "hinzufuegen"):
|
||||
continue
|
||||
if target not in ("title", "description", "examples", "hints"):
|
||||
continue
|
||||
if action in ("anpassen", "hinzufuegen") and not content:
|
||||
continue
|
||||
if action == "entfernen" and target not in ("examples", "hints"):
|
||||
continue
|
||||
# Index nur für anpassen/entfernen in Listen-Feldern; muss existieren
|
||||
if target in ("examples", "hints") and action in ("anpassen", "entfernen"):
|
||||
if not isinstance(index, int) or not (0 <= index < len(element[target])):
|
||||
continue
|
||||
else:
|
||||
index = None
|
||||
if target == "examples" and action in ("anpassen", "hinzufuegen"):
|
||||
content = _fence(content)
|
||||
changes.append({"text": text, "action": action, "target": target, "index": index, "content": content})
|
||||
return changes
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -79,6 +79,66 @@ class GuideChatResponse(BaseModel):
|
||||
reply: str
|
||||
|
||||
|
||||
class ElementResponse(BaseModel):
|
||||
id: str
|
||||
topic: str
|
||||
title: str
|
||||
description: str = ""
|
||||
examples: list[str] = []
|
||||
hints: list[str] = []
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class ElementCreateRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
hint: str = Field(default="", max_length=500)
|
||||
provider: ProviderType = "claude"
|
||||
|
||||
|
||||
class ElementUpdateRequest(BaseModel):
|
||||
title: str | None = Field(default=None, max_length=200)
|
||||
description: str | None = None
|
||||
examples: list[str] | None = None
|
||||
hints: list[str] | None = None
|
||||
|
||||
|
||||
class ElementCheckRequest(BaseModel):
|
||||
provider: ProviderType = "claude"
|
||||
|
||||
|
||||
class ElementSuggestion(BaseModel):
|
||||
text: str
|
||||
target: Literal["description", "examples", "hints"]
|
||||
content: str
|
||||
|
||||
|
||||
class ElementCheckResponse(BaseModel):
|
||||
suggestions: list[ElementSuggestion]
|
||||
|
||||
|
||||
class ElementStyleChange(BaseModel):
|
||||
text: str
|
||||
action: Literal["entfernen", "anpassen", "hinzufuegen"]
|
||||
target: Literal["title", "description", "examples", "hints"]
|
||||
index: int | None = None
|
||||
content: str = ""
|
||||
|
||||
|
||||
class ElementStyleResponse(BaseModel):
|
||||
changes: list[ElementStyleChange]
|
||||
|
||||
|
||||
class ElementChatRequest(BaseModel):
|
||||
messages: list[ChatMessage] = Field(min_length=1)
|
||||
provider: ProviderType = "claude"
|
||||
|
||||
|
||||
class ElementChatResponse(BaseModel):
|
||||
reply: str
|
||||
element: ElementResponse
|
||||
|
||||
|
||||
class ProgressUpdate(BaseModel):
|
||||
chapter: str = Field(min_length=1, max_length=100)
|
||||
done: bool
|
||||
|
||||
@@ -13,16 +13,20 @@ from database import (
|
||||
create_guide, delete_guide, get_guide, list_guides,
|
||||
create_topic, list_topics as db_list_topics, delete_topic,
|
||||
list_progress, set_progress, delete_progress,
|
||||
create_element, list_elements, get_element, update_element, delete_element,
|
||||
)
|
||||
from generator import (
|
||||
generate_guide, cancel_guide, chat_with_guide, guide_slot_dateien,
|
||||
generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine,
|
||||
generate_element, chat_with_element, check_element, style_element,
|
||||
)
|
||||
from models import (
|
||||
GuideCreateRequest, GuideResponse,
|
||||
TopicCreateRequest,
|
||||
BausteineCreateRequest, BausteineStatusResponse,
|
||||
GuideChatRequest, GuideChatResponse,
|
||||
ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse,
|
||||
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
|
||||
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
||||
)
|
||||
from paths import bausteine_path, bausteine_topics, guide_content_path, project_dir, topic_dir
|
||||
@@ -263,6 +267,75 @@ async def guide_chat(guide_id: str, req: GuideChatRequest):
|
||||
return {"reply": reply}
|
||||
|
||||
|
||||
# --- Elemente (persönliche Zusammenfassung) ---
|
||||
|
||||
@router.get("/elements", response_model=list[ElementResponse])
|
||||
async def get_elements(topic: str):
|
||||
return await list_elements(topic)
|
||||
|
||||
|
||||
@router.post("/elements", response_model=ElementResponse)
|
||||
async def post_element(req: ElementCreateRequest):
|
||||
fields = await generate_element(req.topic, req.hint, provider=req.provider)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
element = {"id": str(uuid.uuid4()), "topic": req.topic, **fields, "created_at": now, "updated_at": now}
|
||||
await create_element(element)
|
||||
return element
|
||||
|
||||
|
||||
@router.post("/elements/{element_id}/chat", response_model=ElementChatResponse)
|
||||
async def element_chat(element_id: str, req: ElementChatRequest):
|
||||
element = await get_element(element_id)
|
||||
if element is None:
|
||||
raise HTTPException(404, "Element nicht gefunden")
|
||||
reply, fields = await chat_with_element(element, [m.model_dump() for m in req.messages], provider=req.provider)
|
||||
if fields:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_element(element_id, **fields, updated_at=now)
|
||||
element = await get_element(element_id)
|
||||
return {"reply": reply, "element": element}
|
||||
|
||||
|
||||
@router.put("/elements/{element_id}", response_model=ElementResponse)
|
||||
async def put_element(element_id: str, req: ElementUpdateRequest):
|
||||
if await get_element(element_id) is None:
|
||||
raise HTTPException(404, "Element nicht gefunden")
|
||||
fields = req.model_dump(exclude_unset=True, exclude_none=True)
|
||||
if fields:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_element(element_id, **fields, updated_at=now)
|
||||
return await get_element(element_id)
|
||||
|
||||
|
||||
@router.post("/elements/{element_id}/style", response_model=ElementStyleResponse)
|
||||
async def element_style(element_id: str, req: ElementCheckRequest):
|
||||
element = await get_element(element_id)
|
||||
if element is None:
|
||||
raise HTTPException(404, "Element nicht gefunden")
|
||||
changes = await style_element(element, provider=req.provider)
|
||||
if changes is None:
|
||||
raise HTTPException(502, "Stil-Prüfung fehlgeschlagen — bitte erneut versuchen")
|
||||
return {"changes": changes}
|
||||
|
||||
|
||||
@router.post("/elements/{element_id}/check", response_model=ElementCheckResponse)
|
||||
async def element_check(element_id: str, req: ElementCheckRequest):
|
||||
element = await get_element(element_id)
|
||||
if element is None:
|
||||
raise HTTPException(404, "Element nicht gefunden")
|
||||
suggestions = await check_element(element, provider=req.provider)
|
||||
if suggestions is None:
|
||||
raise HTTPException(502, "Prüfung fehlgeschlagen — bitte erneut versuchen")
|
||||
return {"suggestions": suggestions}
|
||||
|
||||
|
||||
@router.delete("/elements/{element_id}")
|
||||
async def remove_element(element_id: str):
|
||||
if not await delete_element(element_id):
|
||||
raise HTTPException(404, "Element nicht gefunden")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/guides/{guide_id}/cancel")
|
||||
async def cancel(guide_id: str):
|
||||
cancelled = await cancel_guide(guide_id)
|
||||
|
||||
Reference in New Issue
Block a user