This commit is contained in:
Team3
2026-05-31 18:04:56 +02:00
parent d1871234bb
commit d4f4f39c32
9 changed files with 349 additions and 3 deletions

View File

@@ -550,6 +550,36 @@ Kein weiterer Text, nur das JSON-Array.
""" """
def _build_topic_suggest_prompt(problem: str, existing_topics: list[str]) -> str:
template = (TEMPLATES_DIR / "Format" / "Suche.md").read_text(encoding="utf-8")
existing = "\n".join(f"- {t}" for t in existing_topics) if existing_topics else "(keine)"
return template.replace("{problem}", problem).replace("{existing}", existing)
async def suggest_topics(problem: str, existing_topics: list[str] | None = None) -> list[dict]:
try:
prompt = _build_topic_suggest_prompt(problem, existing_topics or [])
returncode, stdout, stderr = await _run_claude(
"topic-suggest-" + str(uuid.uuid4()), prompt, 120, tools=None, model=MODEL_BAUSTEIN_GEN
)
if returncode != 0:
return []
items = _parse_json(stdout)
if not isinstance(items, list):
return []
result = []
for item in items:
if not isinstance(item, dict):
continue
title = str(item.get("title", "")).strip()[:100]
if not title:
continue
result.append({"title": title, "reason": str(item.get("reason", "")).strip()})
return result
except Exception:
return []
async def sort_bausteine(topic: str, bausteine: list[dict], instructions: str = "") -> None: async def sort_bausteine(topic: str, bausteine: list[dict], instructions: str = "") -> None:
_sorting.add(topic) _sorting.add(topic)
try: try:

View File

@@ -68,3 +68,12 @@ class SuggestionResponse(BaseModel):
example: str example: str
status: str status: str
created_at: str created_at: str
class TopicSuggestRequest(BaseModel):
problem: str = Field(min_length=1, max_length=2000)
class TopicSuggestion(BaseModel):
title: str
reason: str

View File

@@ -11,10 +11,11 @@ from database import (
create_baustein as db_create_baustein, list_bausteine, get_baustein, delete_baustein as db_delete_baustein, create_baustein as db_create_baustein, list_bausteine, get_baustein, delete_baustein as db_delete_baustein,
list_suggestions, get_suggestion, update_suggestion, delete_suggestion, list_suggestions, get_suggestion, update_suggestion, delete_suggestion,
) )
from generator import generate_guide, rework_guide, cancel_guide, generate_suggestions, generate_baustein_detail, rework_baustein, sort_bausteine, is_suggestions_generating, is_sorting from generator import generate_guide, rework_guide, cancel_guide, generate_suggestions, generate_baustein_detail, rework_baustein, sort_bausteine, suggest_topics, is_suggestions_generating, is_sorting
from models import ( from models import (
GuideCreateRequest, GuideReworkRequest, GuideResponse, GuideCreateRequest, GuideReworkRequest, GuideResponse,
BausteinCreateRequest, BausteinReworkRequest, BausteinSortRequest, BausteinResponse, SuggestionResponse, BausteinCreateRequest, BausteinReworkRequest, BausteinSortRequest, BausteinResponse, SuggestionResponse,
TopicSuggestRequest, TopicSuggestion,
) )
from paths import final_paths from paths import final_paths
@@ -26,6 +27,13 @@ async def get_formats():
return FORMAT_META return FORMAT_META
@router.post("/topic-suggestions", response_model=list[TopicSuggestion])
async def topic_suggestions(req: TopicSuggestRequest):
guides = await list_guides()
existing_topics = sorted({g["topic"] for g in guides})
return await suggest_topics(req.problem.strip(), existing_topics)
@router.post("/guides", response_model=GuideResponse) @router.post("/guides", response_model=GuideResponse)
async def create(req: GuideCreateRequest): async def create(req: GuideCreateRequest):
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()

View File

@@ -4,12 +4,14 @@ import { fetchGuides, createGuide as apiCreate, deleteGuide, cancelGuide as apiC
import TopicSidebar from './components/TopicSidebar.vue' import TopicSidebar from './components/TopicSidebar.vue'
import TopicDetail from './components/TopicDetail.vue' import TopicDetail from './components/TopicDetail.vue'
import BausteineView from './components/BausteineView.vue' import BausteineView from './components/BausteineView.vue'
import HelpChat from './components/HelpChat.vue'
const guides = ref([]) const guides = ref([])
const manualTopics = ref([]) const manualTopics = ref([])
const selectedTopic = ref(null) const selectedTopic = ref(null)
const previewGuide = ref(null) const previewGuide = ref(null)
const showBausteine = ref(false) const showBausteine = ref(false)
const showHelp = ref(false)
const bausteineRefreshKey = ref(0) const bausteineRefreshKey = ref(0)
const sidebarPinned = ref(localStorage.getItem('sidebarPinned') !== 'false') const sidebarPinned = ref(localStorage.getItem('sidebarPinned') !== 'false')
const sidebarSticky = ref(false) const sidebarSticky = ref(false)
@@ -94,6 +96,7 @@ function selectTopic(topic) {
selectedTopic.value = topic selectedTopic.value = topic
previewGuide.value = null previewGuide.value = null
showBausteine.value = false showBausteine.value = false
showHelp.value = false
nextTick(autoPreview) nextTick(autoPreview)
} }
@@ -103,6 +106,11 @@ function createTopic(topic) {
} }
selectedTopic.value = topic selectedTopic.value = topic
previewGuide.value = null previewGuide.value = null
showHelp.value = false
}
function onHelpSelect(title) {
createTopic(title)
} }
async function handleFormatClick({ format, instructions }) { async function handleFormatClick({ format, instructions }) {
@@ -221,9 +229,15 @@ onUnmounted(() => {
@addBaustein="handleSidebarAddBaustein" @addBaustein="handleSidebarAddBaustein"
@togglePin="toggleSidebarPin" @togglePin="toggleSidebarPin"
@sidebarLeave="onSidebarLeave" @sidebarLeave="onSidebarLeave"
@openHelp="showHelp = true"
/>
<HelpChat
v-if="showHelp"
@close="showHelp = false"
@selectTopic="onHelpSelect"
/> />
<BausteineView <BausteineView
v-if="selectedTopic && showBausteine" v-else-if="selectedTopic && showBausteine"
:topic="selectedTopic" :topic="selectedTopic"
:refreshKey="bausteineRefreshKey" :refreshKey="bausteineRefreshKey"
/> />

View File

@@ -44,6 +44,15 @@ export function htmlUrl(id) {
return `${BASE}/guides/${id}/html` return `${BASE}/guides/${id}/html`
} }
export async function suggestTopics(problem) {
const res = await fetch(`${BASE}/topic-suggestions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ problem }),
})
return res.json()
}
export async function fetchBausteine(topic) { export async function fetchBausteine(topic) {
const res = await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`) const res = await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`)
return res.json() return res.json()

View File

@@ -0,0 +1,214 @@
<script setup>
import { ref } from 'vue'
import { suggestTopics } from '../api.js'
const emit = defineEmits(['close', 'selectTopic'])
const problem = ref('')
const loading = ref(false)
const submitted = ref(false)
const suggestions = ref([])
const error = ref(false)
async function submit() {
const text = problem.value.trim()
if (!text || loading.value) return
loading.value = true
submitted.value = true
error.value = false
suggestions.value = []
try {
const result = await suggestTopics(text)
suggestions.value = Array.isArray(result) ? result : []
} catch (e) {
error.value = true
} finally {
loading.value = false
}
}
function pick(title) {
emit('selectTopic', title)
}
</script>
<template>
<main class="help-chat">
<header class="help-header">
<h2>Passendes Thema finden</h2>
<button class="close-btn" title="Schließen" @click="emit('close')">×</button>
</header>
<div class="help-body">
<p class="hint">Beschreibe dein Problem oder Lernziel die KI schlägt dir passende Themen-Namen vor, zu denen du einen Guide generieren kannst.</p>
<div class="input-row">
<textarea
v-model="problem"
placeholder="Womit hast du Probleme? Was möchtest du lernen?"
:disabled="loading"
@keyup.enter.exact.prevent="submit"
></textarea>
<button
class="send-btn"
:disabled="!problem.trim() || loading"
@click="submit"
>{{ loading ? '…' : 'Themen finden' }}</button>
</div>
<div v-if="loading" class="status">Suche Themen</div>
<div v-else-if="error" class="status error">Fehler bei der Anfrage. Bitte erneut versuchen.</div>
<div v-else-if="submitted && !suggestions.length" class="status">Keine Vorschläge gefunden.</div>
<ul v-else class="suggestions">
<li v-for="(s, i) in suggestions" :key="i" class="suggestion" @click="pick(s.title)">
<span class="suggestion-title">{{ s.title }}</span>
<span class="suggestion-reason" v-if="s.reason">{{ s.reason }}</span>
</li>
</ul>
</div>
</main>
</template>
<style scoped>
.help-chat {
flex: 1;
height: 100vh;
display: flex;
flex-direction: column;
background: #f8f9fb;
overflow: hidden;
}
.help-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: #fff;
border-bottom: 1px solid #e2e5e9;
}
.help-header h2 {
font-size: 1.1rem;
font-weight: 700;
color: #1a1a1a;
}
.close-btn {
border: none;
background: none;
font-size: 1.6rem;
line-height: 1;
color: #4b5563;
cursor: pointer;
padding: 0 6px;
}
.close-btn:hover {
color: #6366f1;
}
.help-body {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
max-width: 760px;
width: 100%;
margin: 0 auto;
}
.hint {
color: #5a6470;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.input-row {
display: flex;
gap: 8px;
align-items: flex-end;
}
.input-row textarea {
flex: 1;
min-height: 70px;
resize: vertical;
padding: 8px 10px;
border: 1px solid #d8dde3;
border-radius: 6px;
font-size: 0.9rem;
font-family: inherit;
outline: none;
}
.input-row textarea:focus {
border-color: #6366f1;
}
.input-row textarea:disabled {
background: #f3f4f6;
}
.send-btn {
padding: 8px 14px;
border: none;
background: #6366f1;
color: #fff;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.status {
margin-top: 1.25rem;
color: #5a6470;
font-size: 0.9rem;
}
.status.error {
color: #991b1b;
}
.suggestions {
list-style: none;
margin-top: 1.25rem;
display: flex;
flex-direction: column;
gap: 8px;
}
.suggestion {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 14px;
background: #d1fae5;
border: 1px solid #34d399;
border-radius: 8px;
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
}
.suggestion:hover {
background: #a7f3d0;
border-color: #059669;
}
.suggestion-title {
font-weight: 700;
color: #065f46;
}
.suggestion-reason {
font-size: 0.82rem;
color: #047857;
}
</style>

View File

@@ -11,7 +11,7 @@ const props = defineProps({
pinned: { type: Boolean, default: true }, pinned: { type: Boolean, default: true },
}) })
const emit = defineEmits(['select', 'create', 'formatClick', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'preview', 'rework', 'showBausteine', 'addBaustein', 'togglePin', 'sidebarLeave']) const emit = defineEmits(['select', 'create', 'formatClick', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'preview', 'rework', 'showBausteine', 'addBaustein', 'togglePin', 'sidebarLeave', 'openHelp'])
const quickBausteinTitle = ref('') const quickBausteinTitle = ref('')
@@ -140,6 +140,11 @@ function confirmDeleteTopic(topic) {
:title="pinned ? 'Sidebar ausblenden' : 'Sidebar fixieren'" :title="pinned ? 'Sidebar ausblenden' : 'Sidebar fixieren'"
@click="emit('togglePin')" @click="emit('togglePin')"
>{{ pinned ? '⇤' : '⇥' }}</button> >{{ pinned ? '⇤' : '⇥' }}</button>
<button
class="help-btn"
title="Passendes Thema zu deinem Problem finden"
@click="emit('openHelp')"
>?</button>
<input <input
v-model="newTopic" v-model="newTopic"
placeholder="Neues Thema…" placeholder="Neues Thema…"
@@ -285,6 +290,20 @@ function confirmDeleteTopic(topic) {
border-color: #a5b4fc; border-color: #a5b4fc;
} }
.new-topic .help-btn {
background: #f8f9fb;
color: #4b5563;
border: 1px solid #d8dde3;
font-weight: 700;
padding: 6px 9px;
}
.new-topic .help-btn:hover {
background: #ede9fe;
color: #4f46e5;
border-color: #a5b4fc;
}
.topic-list { .topic-list {
list-style: none; list-style: none;
flex: 1; flex: 1;

43
templates/Format/Suche.md Normal file
View File

@@ -0,0 +1,43 @@
Du erstellst eine Themen-Taxonomie. Ein Nutzer beschreibt ein Problem oder
Lernziel. Leite daraus 7 THEMEN-NAMEN ab, zu denen jeweils ein vollständiger
Lern-Guide erstellt werden kann, der das Thema komplett vermittelt, um das
Problem zu lösen.
Problem des Nutzers:
{problem}
BEREITS VORHANDENE THEMEN (Referenz diese NICHT erneut vorschlagen):
{existing}
GRUNDPRINZIP MECE (Mutually Exclusive, Collectively Exhaustive):
Die Themen müssen sich gegenseitig ausschließen (kein Thema ist ein Unterthema
eines anderen, keine inhaltliche Überschneidung) und zusammen das Problem
sinnvoll abdecken. Alle Themen stehen auf der GLEICHEN Abstraktionsebene
gleichrangige Hauptthemen, niemals eine Mischung aus Ober- und Unterthemen.
Stil der Themen-Namen:
- KURZ und prägnant: 13 Wörter, wie ein Buchregal-Etikett, KEIN Satz.
- Thema VOLLSTÄNDIG benennen, keine Teilmenge. Jeder Guide deckt sein Thema
komplett ab, daher KEINE Zusätze wie "Grundlagen", "Basics", "Einführung".
Richtig: "CSS" — Falsch: "CSS Grundlagen".
- KEIN Doppelpunkt, KEINE Erklärung, KEINE Marketing-Sprache im Titel.
- Beispiele für guten Stil: "HTML", "CSS", "JavaScript", "Deployment", "Git".
Inhaltliche Anforderungen:
- Treffe das konkrete Problem, nicht nur das Oberthema.
- Bei vagem Problem: Themen für die wahrscheinlichsten Interpretationen.
- Die Themen müssen NICHT existieren wähle gut benennbare, präzise Titel.
PFLICHT-PRÜFSCHRITT vor der Ausgabe (intern, NICHT mit ausgeben):
Erstelle zuerst einen Entwurf von 7 Themen. Prüfe dann JEDES Paar (A, B):
Könnte Thema A sinnvoll als Kapitel im Guide zu Thema B behandelt werden?
Wenn ja, verletzt das die MECE-Regel → ersetze A durch ein gleichrangiges,
disjunktes Thema. Wiederhole, bis kein Thema mehr ein Unterthema eines anderen
ist. Beispiele für Verstöße: "Responsive Design" gehört in "CSS";
"Domain & DNS" gehört in "Deployment". Gib erst danach das finale Set aus.
AUSGABE:
Antworte AUSSCHLIESSLICH mit einem JSON-Array von 7 Elementen. Jedes Element:
- "title": kurzer Themen-Name (max. 30 Zeichen, 13 Wörter)
- "reason": ein Satz, wie dieser Guide das Problem konkret löst
Kein weiterer Text, nur das JSON-Array.

BIN
templates/Format/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB