update
This commit is contained in:
6
Makefile
6
Makefile
@@ -65,12 +65,11 @@ ollama:
|
|||||||
# Remote wird für die DB-Kopie gestoppt (sonst reißt der Snapshot mitten im Schreibvorgang)
|
# Remote wird für die DB-Kopie gestoppt (sonst reißt der Snapshot mitten im Schreibvorgang)
|
||||||
# und danach wieder gestartet.
|
# und danach wieder gestartet.
|
||||||
sync: stop
|
sync: stop
|
||||||
@mkdir -p storage/themen storage/topics uni
|
@mkdir -p storage/topics uni
|
||||||
@rm -f storage/creator.db-shm storage/creator.db-wal
|
@rm -f storage/creator.db-shm storage/creator.db-wal
|
||||||
ssh root@178.104.67.87 'cd /var/www/creator && docker compose down'
|
ssh root@178.104.67.87 'cd /var/www/creator && docker compose down'
|
||||||
rsync -avz --progress root@178.104.67.87:/var/www/creator/storage/creator.db storage/
|
rsync -avz --progress root@178.104.67.87:/var/www/creator/storage/creator.db storage/
|
||||||
-rsync -avz --progress root@178.104.67.87:/var/www/creator/storage/creator.db-wal storage/
|
-rsync -avz --progress root@178.104.67.87:/var/www/creator/storage/creator.db-wal storage/
|
||||||
rsync -avz --progress --delete root@178.104.67.87:/var/www/creator/storage/themen/ storage/themen/
|
|
||||||
rsync -avz --progress --delete root@178.104.67.87:/var/www/creator/storage/topics/ storage/topics/
|
rsync -avz --progress --delete root@178.104.67.87:/var/www/creator/storage/topics/ storage/topics/
|
||||||
rsync -avz --progress --delete root@178.104.67.87:/var/www/creator/uni/ uni/
|
rsync -avz --progress --delete root@178.104.67.87:/var/www/creator/uni/ uni/
|
||||||
ssh root@178.104.67.87 'cd /var/www/creator && docker compose up -d'
|
ssh root@178.104.67.87 'cd /var/www/creator && docker compose up -d'
|
||||||
@@ -90,13 +89,12 @@ sync-all: sync sync-projects
|
|||||||
# Stoppt den Remote-Server (DB-Sicherheit), pusht DB + Themen + Projekte, startet ihn neu.
|
# Stoppt den Remote-Server (DB-Sicherheit), pusht DB + Themen + Projekte, startet ihn neu.
|
||||||
# --delete = exakter Spiegel: Remote-Dateien ohne lokales Pendant werden gelöscht.
|
# --delete = exakter Spiegel: Remote-Dateien ohne lokales Pendant werden gelöscht.
|
||||||
sync-all-reverse: stop
|
sync-all-reverse: stop
|
||||||
@echo "ACHTUNG: überschreibt den Remote-Stand (DB, themen/, topics/, uni/, projects/) mit dem lokalen."
|
@echo "ACHTUNG: überschreibt den Remote-Stand (DB, topics/, uni/, projects/) mit dem lokalen."
|
||||||
@[ -f storage/creator.db ] || { echo "Keine lokale DB — abgebrochen."; exit 1; }
|
@[ -f storage/creator.db ] || { echo "Keine lokale DB — abgebrochen."; exit 1; }
|
||||||
ssh root@178.104.67.87 'cd /var/www/creator && docker compose down'
|
ssh root@178.104.67.87 'cd /var/www/creator && docker compose down'
|
||||||
ssh root@178.104.67.87 'rm -f /var/www/creator/storage/creator.db-shm /var/www/creator/storage/creator.db-wal'
|
ssh root@178.104.67.87 'rm -f /var/www/creator/storage/creator.db-shm /var/www/creator/storage/creator.db-wal'
|
||||||
rsync -avz --progress storage/creator.db root@178.104.67.87:/var/www/creator/storage/
|
rsync -avz --progress storage/creator.db root@178.104.67.87:/var/www/creator/storage/
|
||||||
@[ -f storage/creator.db-wal ] && rsync -avz --progress storage/creator.db-wal root@178.104.67.87:/var/www/creator/storage/ || true
|
@[ -f storage/creator.db-wal ] && rsync -avz --progress storage/creator.db-wal root@178.104.67.87:/var/www/creator/storage/ || true
|
||||||
rsync -avz --progress --delete storage/themen/ root@178.104.67.87:/var/www/creator/storage/themen/
|
|
||||||
rsync -avz --progress --delete storage/topics/ root@178.104.67.87:/var/www/creator/storage/topics/
|
rsync -avz --progress --delete storage/topics/ root@178.104.67.87:/var/www/creator/storage/topics/
|
||||||
rsync -avz --progress --delete uni/ root@178.104.67.87:/var/www/creator/uni/
|
rsync -avz --progress --delete uni/ root@178.104.67.87:/var/www/creator/uni/
|
||||||
rsync -avz --progress --delete projects/ root@178.104.67.87:/var/www/creator/projects/
|
rsync -avz --progress --delete projects/ root@178.104.67.87:/var/www/creator/projects/
|
||||||
|
|||||||
@@ -276,24 +276,6 @@ def _blocks_files(topic: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _all_slot_files(files: dict) -> list[Path]:
|
|
||||||
work_dir = files["arbeit"]
|
|
||||||
# Subblock/levels slots are dynamic per chunk — collect via glob.
|
|
||||||
dyn = (list(work_dir.glob("subblock-*")) + list(work_dir.glob("facts-*")) + list(work_dir.glob("level-*")) + list(work_dir.glob("relevance-*"))
|
|
||||||
+ list(work_dir.glob("question-pattern-*")) + list(work_dir.glob("outline-*")) + list(work_dir.glob("artifact-*"))
|
|
||||||
+ list(work_dir.glob("research-*")) + list(work_dir.glob("consolidation-*"))
|
|
||||||
+ list(work_dir.glob("clarification*")) + list(work_dir.glob("dedup-*"))
|
|
||||||
+ list(work_dir.glob("inventar-filter*"))
|
|
||||||
+ list(work_dir.glob("gruppierung-*")) + list(work_dir.glob("inventar-gruppierung*"))) if work_dir.is_dir() else []
|
|
||||||
return [
|
|
||||||
*files["research"], files["research_mapping"],
|
|
||||||
*(p for slots in files["selection"].values() for p in slots),
|
|
||||||
*files["mapping"].values(), files["ergaenzung"],
|
|
||||||
files["sub_roh"], files["sidecar"], files["question_pattern"],
|
|
||||||
files["facts"], files["outline"], files["artefakte"], *dyn,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def cancel_blocks(topic: str) -> bool:
|
def cancel_blocks(topic: str) -> bool:
|
||||||
if topic not in _blocks_progress:
|
if topic not in _blocks_progress:
|
||||||
return False
|
return False
|
||||||
@@ -655,54 +637,6 @@ def _file_payload(path: Path):
|
|||||||
return text if _parse_selection(text) else None
|
return text if _parse_selection(text) else None
|
||||||
|
|
||||||
|
|
||||||
def _mapping_schema(data):
|
|
||||||
"""{"blocks": [str, ≥1], "rest": [str]} → (blocks, rest) · otherwise None."""
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return None
|
|
||||||
blocks = _str_list(data.get("blocks"))
|
|
||||||
rest = _str_list(data.get("rest"))
|
|
||||||
if not blocks or rest is None:
|
|
||||||
return None
|
|
||||||
return blocks, rest
|
|
||||||
|
|
||||||
|
|
||||||
def _sub_raw_schema(data):
|
|
||||||
"""{block title: [subblock, …]} → dict · otherwise None (intermediate state of block B)."""
|
|
||||||
if not isinstance(data, dict) or not data:
|
|
||||||
return None
|
|
||||||
out: dict[str, list[str]] = {}
|
|
||||||
for k, v in data.items():
|
|
||||||
subs = _str_list(v) if isinstance(v, list) else None
|
|
||||||
if not isinstance(k, str) or not k.strip() or not subs:
|
|
||||||
return None
|
|
||||||
out[k] = subs
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_schema(data):
|
|
||||||
"""{block title: [{title, level}, …]} → dict · otherwise None (sidecar with levels)."""
|
|
||||||
if not isinstance(data, dict) or not data:
|
|
||||||
return None
|
|
||||||
for v in data.values():
|
|
||||||
if not isinstance(v, list) or not v:
|
|
||||||
return None
|
|
||||||
for s in v:
|
|
||||||
if not isinstance(s, dict) or not str(s.get("title", "")).strip() or s.get("level") not in _LEVELS:
|
|
||||||
return None
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def _relevance_complete(data) -> bool:
|
|
||||||
"""Does every subblock in the sidecar carry a valid relevance (relevant/peripheral)?"""
|
|
||||||
if not isinstance(data, dict) or not data:
|
|
||||||
return False
|
|
||||||
return all(
|
|
||||||
isinstance(s, dict) and s.get("relevance") in ("relevant", "peripheral")
|
|
||||||
for v in data.values() if isinstance(v, list)
|
|
||||||
for s in v
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _question_pattern_chunk_schema(data) -> list[dict] | None:
|
def _question_pattern_chunk_schema(data) -> list[dict] | None:
|
||||||
@@ -725,12 +659,6 @@ def _question_pattern_chunk_schema(data) -> list[dict] | None:
|
|||||||
return out or None
|
return out or None
|
||||||
|
|
||||||
|
|
||||||
def _question_pattern_complete(topic: str) -> bool:
|
|
||||||
"""Does the question-pattern sidecar exist (build ran)? Individual empty blocks
|
|
||||||
fall back to live generation at exam time — so the file is enough."""
|
|
||||||
return isinstance(_json_file(question_pattern_path(topic)), dict)
|
|
||||||
|
|
||||||
|
|
||||||
def _read(p: Path) -> str:
|
def _read(p: Path) -> str:
|
||||||
return p.read_text(encoding="utf-8") if p.exists() else ""
|
return p.read_text(encoding="utf-8") if p.exists() else ""
|
||||||
|
|
||||||
@@ -1457,12 +1385,6 @@ def _facts_lines(fk: dict) -> str:
|
|||||||
return "\n".join(z)
|
return "\n".join(z)
|
||||||
|
|
||||||
|
|
||||||
def _facts_complete(files: dict) -> bool:
|
|
||||||
"""Does the facts map exist (block done)? {block: {sub_norm: {...}}}."""
|
|
||||||
d = _json_file(files["facts"])
|
|
||||||
return isinstance(d, dict) and bool(d)
|
|
||||||
|
|
||||||
|
|
||||||
async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, instructions: str, ns: str = "", lbl: str = "", sources: list[str] | None = None) -> tuple | None:
|
async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, instructions: str, ns: str = "", lbl: str = "", sources: list[str] | None = None) -> tuple | None:
|
||||||
"""Block: per sub extract source facts (find) → verify (check) → correct/discard (fix).
|
"""Block: per sub extract source facts (find) → verify (check) → correct/discard (fix).
|
||||||
Extract-once grounding: the result feeds level/relevance/questions/guide.
|
Extract-once grounding: the result feeds level/relevance/questions/guide.
|
||||||
@@ -2043,18 +1965,6 @@ def _crawl_index(folder) -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def _set_inventory(topic: str, record: str, status: str) -> None:
|
|
||||||
"""Write an inventory entry ('title — description') with status to the DB."""
|
|
||||||
title = _title(record)
|
|
||||||
norm = _norm_title(title)
|
|
||||||
if not norm:
|
|
||||||
return
|
|
||||||
split_parts = [t.strip() for t in record.split(" — ")]
|
|
||||||
desc = split_parts[1] if len(split_parts) >= 2 else ""
|
|
||||||
await db.upsert_block(topic, norm, title, desc)
|
|
||||||
await db.set_block_status(topic, norm, status)
|
|
||||||
|
|
||||||
|
|
||||||
def _triage_rules(folder, pages: list[str]) -> tuple[list[str], list[str]]:
|
def _triage_rules(folder, pages: list[str]) -> tuple[list[str], list[str]]:
|
||||||
"""Deterministic content/noise filter (config.CRAWL_*). Substring match (lowercase) against
|
"""Deterministic content/noise filter (config.CRAWL_*). Substring match (lowercase) against
|
||||||
URL + filename. Order: keep > noise > min_chars > keep. → (content, noise)."""
|
URL + filename. Order: keep > noise > min_chars > keep. → (content, noise)."""
|
||||||
@@ -2324,6 +2234,22 @@ _CANON_CATALOGUE = re.compile(
|
|||||||
r'|kapitel|chapter|abschnitt|section)\s*\d+(?:\.\d+)*\b', re.I)
|
r'|kapitel|chapter|abschnitt|section)\s*\d+(?:\.\d+)*\b', re.I)
|
||||||
|
|
||||||
|
|
||||||
|
_PAREN_GROUP = re.compile(r'^\s*(.*?)\s*\(([^()]{2,60})\)\s*$')
|
||||||
|
|
||||||
|
|
||||||
|
def _title_variants(title: str) -> set[str]:
|
||||||
|
"""Acronym/expansion variants of a "X (Y)" title — normalized outer part and paren
|
||||||
|
content. „Satisfiability Problem (SAT)" → {'satisfiability problem', 'sat'}: matched
|
||||||
|
against another card's norm/key this makes acronym↔expansion pairs dedup CANDIDATES
|
||||||
|
(measured: title cosine 'sat' vs the long form is 0.53, far below the floor).
|
||||||
|
Titles without exactly one trailing paren group → empty set."""
|
||||||
|
from textkit import _norm_title
|
||||||
|
m = _PAREN_GROUP.match(title or "")
|
||||||
|
if not m:
|
||||||
|
return set()
|
||||||
|
return {v for v in (_norm_title(m.group(1)), _norm_title(m.group(2))) if v}
|
||||||
|
|
||||||
|
|
||||||
def _canonical_key(title: str) -> str:
|
def _canonical_key(title: str) -> str:
|
||||||
"""Order-independent canonical key of a title (scaffolding stripped, relation operators normalized).
|
"""Order-independent canonical key of a title (scaffolding stripped, relation operators normalized).
|
||||||
Two titles with the same key denote the same entity with ~100% precision (ER blocking). Empty string
|
Two titles with the same key denote the same entity with ~100% precision (ER blocking). Empty string
|
||||||
@@ -2622,12 +2548,6 @@ _GROUP_STANDALONE = re.compile(
|
|||||||
|
|
||||||
# --- Outline (blocks artifact: chapter structure, only read by the guide) ---
|
# --- Outline (blocks artifact: chapter structure, only read by the guide) ---
|
||||||
|
|
||||||
def _outline_complete(files: dict) -> bool:
|
|
||||||
"""Is the outline present (chapter list exists)?"""
|
|
||||||
d = _json_file(files["outline"])
|
|
||||||
return isinstance(d, dict) and isinstance(d.get("chapters"), list) and bool(d.get("chapters"))
|
|
||||||
|
|
||||||
|
|
||||||
def _outline_review_schema(data, valid: set[int], n_chapters: int, n_blocks: int):
|
def _outline_review_schema(data, valid: set[int], n_chapters: int, n_blocks: int):
|
||||||
"""{"moves": {"<blocknr>": <chapter-idx>}} → {nr: idx} (may be {}) · None if broken/invalid.
|
"""{"moves": {"<blocknr>": <chapter-idx>}} → {nr: idx} (may be {}) · None if broken/invalid.
|
||||||
A mass rewrite (more than a third of all blocks) is rejected — the reviewer's job is
|
A mass rewrite (more than a third of all blocks) is rejected — the reviewer's job is
|
||||||
@@ -2898,12 +2818,6 @@ _ARTEFACT_PROMPT = {"flashcard": "Artifact-Flashcard", "example": "Artifact-Exam
|
|||||||
_ARTEFACT_STEP = {"flashcard": "Flashcards", "example": "Examples"}
|
_ARTEFACT_STEP = {"flashcard": "Flashcards", "example": "Examples"}
|
||||||
|
|
||||||
|
|
||||||
def _artefacts_complete(files: dict) -> bool:
|
|
||||||
"""Artifact map present (all types generated)? Values may be empty (content-aware)."""
|
|
||||||
d = _json_file(files["artefakte"])
|
|
||||||
return isinstance(d, dict) and all(t in d for t in ARTEFACT_TYPES)
|
|
||||||
|
|
||||||
|
|
||||||
async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "", lbl: str = "") -> dict | None:
|
async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "", lbl: str = "") -> dict | None:
|
||||||
"""Generate learning artefacts per type from the stored facts — one generation pass
|
"""Generate learning artefacts per type from the stored facts — one generation pass
|
||||||
per type over chunks. Worked examples are verified against the facts (wrong ones discarded);
|
per type over chunks. Worked examples are verified against the facts (wrong ones discarded);
|
||||||
@@ -3009,20 +2923,6 @@ async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
|
|||||||
return outcome
|
return outcome
|
||||||
|
|
||||||
|
|
||||||
async def _mirror_artefacts_db(topic: str, sidecar: dict, artefacts: dict) -> None:
|
|
||||||
"""Mirror artefacts into the DB. Flashcard/example per sub (sub_norm)."""
|
|
||||||
await db.delete_sub_artefakte(topic)
|
|
||||||
btitle_list = list(sidecar.keys())
|
|
||||||
for type in ARTEFACT_TYPES:
|
|
||||||
for e in artefacts.get(type, []):
|
|
||||||
bt = _match_sub(e.get("block", ""), btitle_list)
|
|
||||||
bnorm, sn = _norm_title(bt), _norm_title(e.get("subblock", ""))
|
|
||||||
if not bnorm or not sn:
|
|
||||||
continue
|
|
||||||
data = json.dumps({k: v for k, v in e.items() if k not in ("block", "subblock")}, ensure_ascii=False)
|
|
||||||
await db.put_sub_artifact(topic, bnorm, sn, type, data, bt, e.get("subblock", ""))
|
|
||||||
|
|
||||||
|
|
||||||
async def _mirror_sidecar_db(topic: str, sidecar: dict) -> None:
|
async def _mirror_sidecar_db(topic: str, sidecar: dict) -> None:
|
||||||
"""Mirror the sidecar {block title: [{title, level, relevance}]} into the DB table subblocks."""
|
"""Mirror the sidecar {block title: [{title, level, relevance}]} into the DB table subblocks."""
|
||||||
for btitle, subs in sidecar.items():
|
for btitle, subs in sidecar.items():
|
||||||
@@ -3042,24 +2942,6 @@ async def _mirror_sidecar_db(topic: str, sidecar: dict) -> None:
|
|||||||
facts=facts, status="consensus")
|
facts=facts, status="consensus")
|
||||||
|
|
||||||
|
|
||||||
async def _mirror_question_pattern_db(topic: str, pattern: dict) -> None:
|
|
||||||
"""Mirror question patterns {block title: [{subblock, question}]} into the DB table question_pattern."""
|
|
||||||
await db.delete_question_pattern(topic)
|
|
||||||
for btitle, eintraege in pattern.items():
|
|
||||||
bnorm = _norm_title(btitle)
|
|
||||||
if not bnorm or not isinstance(eintraege, list):
|
|
||||||
continue
|
|
||||||
for e in eintraege:
|
|
||||||
if not isinstance(e, dict):
|
|
||||||
continue
|
|
||||||
sub = str(e.get("subblock", "")).strip()
|
|
||||||
sn = _norm_title(sub)
|
|
||||||
question = str(e.get("question", "")).strip()
|
|
||||||
if not (sn and question):
|
|
||||||
continue
|
|
||||||
await db.upsert_question_pattern(topic, bnorm, sn, btitle, sub, question)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_blocks(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER,
|
async def generate_blocks(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ from blocks import (
|
|||||||
_filter_schema, _filter_suspect, _is_artifact, _is_named_statement,
|
_filter_schema, _filter_suspect, _is_artifact, _is_named_statement,
|
||||||
_is_parentless_noise, _is_reference, _pairs_schema, _read,
|
_is_parentless_noise, _is_reference, _pairs_schema, _read,
|
||||||
_relation_conflict, _root, _supplement_schema, _text_sections, _umbrella_schema,
|
_relation_conflict, _root, _supplement_schema, _text_sections, _umbrella_schema,
|
||||||
_aspect_marker,
|
_aspect_marker, _title_variants, _evidence_pack, _sink_json, source_folder,
|
||||||
)
|
)
|
||||||
from config import (
|
from config import (
|
||||||
BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_AKTIV, EMBEDDING_BLOCK_CAP,
|
BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_AKTIV, EMBEDDING_BLOCK_CAP,
|
||||||
@@ -53,8 +53,8 @@ from config import (
|
|||||||
from fsutil import atomic_write_json, atomic_write_text
|
from fsutil import atomic_write_json, atomic_write_text
|
||||||
from jsonio import read_json_file as _json_file
|
from jsonio import read_json_file as _json_file
|
||||||
from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt,
|
from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt,
|
||||||
_runde_schema, _timeout, run_single_slot)
|
_runde_schema, _timeout, _yesno_schema, run_single_slot)
|
||||||
from textkit import _norm_title, _parse_selection, _title
|
from textkit import _norm_title, _parse_selection, _title, clean_title
|
||||||
|
|
||||||
log = logging.getLogger("creator.board_inventory")
|
log = logging.getLogger("creator.board_inventory")
|
||||||
|
|
||||||
@@ -144,13 +144,13 @@ async def _ingest_titles(flow: Flow, text: str, reader: str, source: str = "") -
|
|||||||
reader union. Repeated drains over a growing buffer are idempotent. → new count."""
|
reader union. Repeated drains over a growing buffer are idempotent. → new count."""
|
||||||
n, seen = 0, set()
|
n, seen = 0, set()
|
||||||
for record in _parse_selection(text).values():
|
for record in _parse_selection(text).values():
|
||||||
title = _title(record)
|
title = clean_title(_title(record))
|
||||||
norm = _norm_title(title)
|
norm = _norm_title(title)
|
||||||
if not norm or norm in seen:
|
if not norm or norm in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(norm) # one reader = one vote per concept
|
seen.add(norm) # one reader = one vote per concept
|
||||||
parts = [t.strip() for t in record.split(" — ")]
|
parts = [t.strip() for t in record.split(" — ")]
|
||||||
desc = parts[1] if len(parts) >= 2 else ""
|
desc = clean_title(parts[1]) if len(parts) >= 2 else ""
|
||||||
src = source or (parts[2] if len(parts) >= 3 else "")
|
src = source or (parts[2] if len(parts) >= 3 else "")
|
||||||
async with _ingest_lock:
|
async with _ingest_lock:
|
||||||
if await db.kanban_add_title(flow.topic, BOARD, norm, title, desc, src, reader):
|
if await db.kanban_add_title(flow.topic, BOARD, norm, title, desc, src, reader):
|
||||||
@@ -924,6 +924,15 @@ async def _proc_dedup(ctx: GenContext, flow: Flow, cards):
|
|||||||
for y in range(x + 1, len(grp)):
|
for y in range(x + 1, len(grp)):
|
||||||
if _demotable_pair(grp[x], grp[y]):
|
if _demotable_pair(grp[x], grp[y]):
|
||||||
pairs.add((grp[x], grp[y]))
|
pairs.add((grp[x], grp[y]))
|
||||||
|
# acronym↔expansion pairs ("SAT" vs "Satisfiability Problem (SAT)"): the title cosine
|
||||||
|
# of short vs long form sits far below the floor — variant match makes them candidates,
|
||||||
|
# the judge panel decides as usual (no auto-merge)
|
||||||
|
idents = [{_norm_title(r["title"]), _canonical_key(r["title"])} - {""} for r in allrows]
|
||||||
|
variants = [_title_variants(r["title"]) for r in allrows]
|
||||||
|
for i in range(n_all):
|
||||||
|
for j in range(i + 1, n_all):
|
||||||
|
if _demotable_pair(i, j) and (variants[i] & idents[j] or variants[j] & idents[i]):
|
||||||
|
pairs.add((i, j))
|
||||||
ordered = sorted(pairs)
|
ordered = sorted(pairs)
|
||||||
h = _h(*[r["card_id"] for r in allrows])
|
h = _h(*[r["card_id"] for r in allrows])
|
||||||
if not ordered:
|
if not ordered:
|
||||||
@@ -1176,7 +1185,7 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
|||||||
readers = sorted(set().union(*[set(r["payload"].get("readers") or []) for r in mrows]))
|
readers = sorted(set().union(*[set(r["payload"].get("readers") or []) for r in mrows]))
|
||||||
srcs = sorted(set().union(*[set(r["payload"].get("sources") or []) for r in mrows]))
|
srcs = sorted(set().union(*[set(r["payload"].get("sources") or []) for r in mrows]))
|
||||||
await db.kanban_upsert_card(topic, BOARD, f"b-u-{uuid.uuid4().hex[:8]}", "block", "gap_check", {
|
await db.kanban_upsert_card(topic, BOARD, f"b-u-{uuid.uuid4().hex[:8]}", "block", "gap_check", {
|
||||||
"title": c["umbrella"], "description": c["description"],
|
"title": clean_title(c["umbrella"]), "description": c["description"],
|
||||||
"readers": readers, "sources": srcs, "umbrella": True,
|
"readers": readers, "sources": srcs, "umbrella": True,
|
||||||
"children": [r["title"] for r in mrows],
|
"children": [r["title"] for r in mrows],
|
||||||
})
|
})
|
||||||
@@ -1221,6 +1230,37 @@ async def _proc_gap_check(ctx: GenContext, flow: Flow, cards):
|
|||||||
flow.wake.set()
|
flow.wake.set()
|
||||||
|
|
||||||
|
|
||||||
|
async def _supplement_beleg(ctx: GenContext, flow: Flow, supplements: list) -> list:
|
||||||
|
"""Evidence gate for supplement proposals: keyword excerpts per proposal, ONE no-tool
|
||||||
|
judge marks material coverage (ja/nein). Proposals without any matching excerpt drop
|
||||||
|
immediately; a failed gate keeps nothing (creep is costlier than a lost bonus round)."""
|
||||||
|
topic = flow.topic
|
||||||
|
folder = source_folder(topic)
|
||||||
|
packs = [(t, d, _evidence_pack(folder, None, [t], budget=6000)) for t, d in supplements]
|
||||||
|
cands = [(t, d, ev) for t, d, ev in packs if ev]
|
||||||
|
kept: list = []
|
||||||
|
if cands:
|
||||||
|
path = flow.work_dir / "supplement-beleg.json"
|
||||||
|
ids = set(range(1, len(cands) + 1))
|
||||||
|
verdict = _yesno_schema(_json_file(path), ids)
|
||||||
|
if verdict is None:
|
||||||
|
lines = "\n\n".join(f"{k}. {t} — {d}\nAUSZÜGE:\n{ev}"
|
||||||
|
for k, (t, d, ev) in enumerate(cands, 1))
|
||||||
|
status, verdict = await run_single_slot(
|
||||||
|
ctx, "Supplement-Beleg", key=f"blocks-{topic}-supplement-beleg",
|
||||||
|
prompt=_prompt("Blocks-Supplement-Beleg", topic=topic, proposals=lines,
|
||||||
|
extra=_extra(flow.state.get("instructions", ""))),
|
||||||
|
role="judge", capabilities="none",
|
||||||
|
payload=lambda result, p=path, i=ids: _sink_json(result, p, lambda d2: _yesno_schema(d2, i)),
|
||||||
|
timeout=_timeout("selection_mapping", len(cands)))
|
||||||
|
if status != OK or not isinstance(verdict, dict):
|
||||||
|
verdict = {}
|
||||||
|
kept = [(t, d) for k, (t, d, ev) in enumerate(cands, 1) if str(verdict.get(k, "nein")) == "ja"]
|
||||||
|
if len(kept) < len(supplements):
|
||||||
|
_log(topic, f"Supplement: {len(supplements) - len(kept)}/{len(supplements)} ohne Materialbeleg verworfen")
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
|
async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
|
||||||
"""One web agent proposes canonically missing blocks → new title cards (reader
|
"""One web agent proposes canonically missing blocks → new title cards (reader
|
||||||
'supplement' skips only the ≥2 consensus bar, every other gate applies)."""
|
'supplement' skips only the ≥2 consensus bar, every other gate applies)."""
|
||||||
@@ -1242,6 +1282,11 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
|
|||||||
if status != OK:
|
if status != OK:
|
||||||
_log(topic, "Supplement fehlgeschlagen — übersprungen (optional)")
|
_log(topic, "Supplement fehlgeschlagen — übersprungen (optional)")
|
||||||
supplements = []
|
supplements = []
|
||||||
|
# Material anchoring (uni/projekt): the web agent proposes canonical knowledge BLIND
|
||||||
|
# to the source (measured: 22/107 aak blocks were textbook standard the script never
|
||||||
|
# treats). Only proposals the material itself covers may enter the inventory.
|
||||||
|
if supplements and source_folder(topic):
|
||||||
|
supplements = await _supplement_beleg(ctx, flow, supplements)
|
||||||
# Dead lineage: blocks demoted by the fragment filter (and their cluster + title cards)
|
# Dead lineage: blocks demoted by the fragment filter (and their cluster + title cards)
|
||||||
# must NOT dedup a supplement proposal — their content is gone. A hit on a dead title
|
# must NOT dedup a supplement proposal — their content is gone. A hit on a dead title
|
||||||
# REOPENS the lineage instead: the title card rejoins its cluster (live re-cluster) and
|
# REOPENS the lineage instead: the title card rejoins its cluster (live re-cluster) and
|
||||||
@@ -1281,6 +1326,7 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
|
|||||||
known_keys.add(k)
|
known_keys.add(k)
|
||||||
new = reopened = 0
|
new = reopened = 0
|
||||||
for t, d in (supplements or []):
|
for t, d in (supplements or []):
|
||||||
|
t, d = clean_title(t), clean_title(d)
|
||||||
norm = _norm_title(t)
|
norm = _norm_title(t)
|
||||||
key = _canonical_key(t)
|
key = _canonical_key(t)
|
||||||
if not norm or norm in known_norms or (key and key in known_keys):
|
if not norm or norm in known_norms or (key and key in known_keys):
|
||||||
|
|||||||
@@ -114,11 +114,6 @@ MAX_CONCURRENT_INTERACTIVE = 8
|
|||||||
# (kill only once the minimum is already in).
|
# (kill only once the minimum is already in).
|
||||||
CONSENSUS_GRACE = 300
|
CONSENSUS_GRACE = 300
|
||||||
|
|
||||||
# Research race: longer grace window. Research drives the whole block count;
|
|
||||||
# with slow providers (e.g. MiniMax) ALL 5 agents should become done, not just
|
|
||||||
# the quorum of 3. The per-agent timeout (TIMEOUTS["research"]=1800s) caps real hangs.
|
|
||||||
RESEARCH_GRACE = 900
|
|
||||||
|
|
||||||
# Cap of the clarification and check loops: maximum rounds until everything must be
|
# Cap of the clarification and check loops: maximum rounds until everything must be
|
||||||
# decided. In the last round the mapping agent MUST decide every entry;
|
# decided. In the last round the mapping agent MUST decide every entry;
|
||||||
# check loops leave any remaining objections standing after that.
|
# check loops leave any remaining objections standing after that.
|
||||||
|
|||||||
@@ -18,15 +18,6 @@ CREATE TABLE IF NOT EXISTS guides (
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
CREATE_PROGRESS = """
|
|
||||||
CREATE TABLE IF NOT EXISTS guide_progress (
|
|
||||||
guide_id TEXT NOT NULL,
|
|
||||||
chapter TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (guide_id, chapter)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
CREATE_TOPICS = """
|
CREATE_TOPICS = """
|
||||||
CREATE TABLE IF NOT EXISTS topics (
|
CREATE TABLE IF NOT EXISTS topics (
|
||||||
name TEXT PRIMARY KEY,
|
name TEXT PRIMARY KEY,
|
||||||
@@ -34,18 +25,6 @@ CREATE TABLE IF NOT EXISTS topics (
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
CREATE_BLOCK_TEXTE = """
|
|
||||||
CREATE TABLE IF NOT EXISTS block_texte (
|
|
||||||
topic TEXT NOT NULL,
|
|
||||||
block TEXT NOT NULL,
|
|
||||||
kind TEXT NOT NULL,
|
|
||||||
md TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (topic, block, kind)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
CREATE_BLOCK_PROGRESS = """
|
CREATE_BLOCK_PROGRESS = """
|
||||||
CREATE TABLE IF NOT EXISTS block_progress (
|
CREATE TABLE IF NOT EXISTS block_progress (
|
||||||
topic TEXT NOT NULL,
|
topic TEXT NOT NULL,
|
||||||
@@ -308,9 +287,7 @@ async def init_db():
|
|||||||
await db.execute("PRAGMA synchronous=NORMAL")
|
await db.execute("PRAGMA synchronous=NORMAL")
|
||||||
await db.execute("PRAGMA busy_timeout=5000")
|
await db.execute("PRAGMA busy_timeout=5000")
|
||||||
await db.execute(CREATE_GUIDES)
|
await db.execute(CREATE_GUIDES)
|
||||||
await db.execute(CREATE_PROGRESS)
|
|
||||||
await db.execute(CREATE_TOPICS)
|
await db.execute(CREATE_TOPICS)
|
||||||
await db.execute(CREATE_BLOCK_TEXTE)
|
|
||||||
await db.execute(CREATE_BLOCK_PROGRESS)
|
await db.execute(CREATE_BLOCK_PROGRESS)
|
||||||
await db.execute(CREATE_BLOCKS)
|
await db.execute(CREATE_BLOCKS)
|
||||||
await db.execute(CREATE_SUBBLOCKS)
|
await db.execute(CREATE_SUBBLOCKS)
|
||||||
@@ -370,16 +347,13 @@ async def init_db():
|
|||||||
await db.execute("ALTER TABLE blocks ADD COLUMN reader TEXT NOT NULL DEFAULT '[]'")
|
await db.execute("ALTER TABLE blocks ADD COLUMN reader TEXT NOT NULL DEFAULT '[]'")
|
||||||
except aiosqlite.OperationalError:
|
except aiosqlite.OperationalError:
|
||||||
pass
|
pass
|
||||||
# Migration: old vertiefungen table → block_texte (existing = long form, kind 'deepdive')
|
# Migration: removed features leave orphaned tables behind — drop them.
|
||||||
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'vertiefungen'")
|
# elements (feature removed), vertiefungen/block_texte (never read), guide_progress
|
||||||
if await cursor.fetchone():
|
# (chapter progress had no frontend and its only reader ignored the value).
|
||||||
await db.execute(
|
|
||||||
"INSERT OR IGNORE INTO block_texte (topic, block, kind, md, created_at, updated_at) "
|
|
||||||
"SELECT topic, block, 'deepdive', md, created_at, updated_at FROM vertiefungen"
|
|
||||||
)
|
|
||||||
await db.execute("DROP TABLE vertiefungen")
|
|
||||||
# Migration: the elements feature was removed entirely — drop its orphaned table.
|
|
||||||
await db.execute("DROP TABLE IF EXISTS elements")
|
await db.execute("DROP TABLE IF EXISTS elements")
|
||||||
|
await db.execute("DROP TABLE IF EXISTS vertiefungen")
|
||||||
|
await db.execute("DROP TABLE IF EXISTS block_texte")
|
||||||
|
await db.execute("DROP TABLE IF EXISTS guide_progress")
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server restart' "
|
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server restart' "
|
||||||
"WHERE status IN ('queued', 'generating')"
|
"WHERE status IN ('queued', 'generating')"
|
||||||
@@ -473,49 +447,6 @@ async def delete_topic(name: str) -> None:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
# --- Chapter progress ---
|
|
||||||
|
|
||||||
async def list_progress_all() -> dict[str, set[str]]:
|
|
||||||
"""Complete chapter progress in one query: guide_id → chapter title."""
|
|
||||||
db = await get_db()
|
|
||||||
cursor = await db.execute("SELECT guide_id, chapter FROM guide_progress")
|
|
||||||
rows = await cursor.fetchall()
|
|
||||||
out: dict[str, set[str]] = {}
|
|
||||||
for guide_id, chapter in rows:
|
|
||||||
out.setdefault(guide_id, set()).add(chapter)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
async def list_progress(guide_id: str) -> list[str]:
|
|
||||||
db = await get_db()
|
|
||||||
cursor = await db.execute(
|
|
||||||
"SELECT chapter FROM guide_progress WHERE guide_id = ?", (guide_id,)
|
|
||||||
)
|
|
||||||
rows = await cursor.fetchall()
|
|
||||||
return [row[0] for row in rows]
|
|
||||||
|
|
||||||
|
|
||||||
async def set_progress(guide_id: str, chapter: str, done: bool) -> None:
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
db = await get_db()
|
|
||||||
if done:
|
|
||||||
await db.execute(
|
|
||||||
"INSERT OR IGNORE INTO guide_progress (guide_id, chapter, created_at) VALUES (?, ?, ?)",
|
|
||||||
(guide_id, chapter, datetime.now(timezone.utc).isoformat()),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await db.execute(
|
|
||||||
"DELETE FROM guide_progress WHERE guide_id = ? AND chapter = ?", (guide_id, chapter)
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_progress(guide_id: str) -> None:
|
|
||||||
db = await get_db()
|
|
||||||
await db.execute("DELETE FROM guide_progress WHERE guide_id = ?", (guide_id,))
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
# --- Block learning: deep-dives + exam progress ---
|
# --- Block learning: deep-dives + exam progress ---
|
||||||
|
|
||||||
def _now() -> str:
|
def _now() -> str:
|
||||||
@@ -667,7 +598,6 @@ async def list_block_scores_all() -> list[tuple[str, str, int]]:
|
|||||||
|
|
||||||
async def delete_block_data(topic: str) -> None:
|
async def delete_block_data(topic: str) -> None:
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
await db.execute("DELETE FROM block_texte WHERE topic = ?", (topic,))
|
|
||||||
await db.execute("DELETE FROM block_progress WHERE topic = ?", (topic,))
|
await db.execute("DELETE FROM block_progress WHERE topic = ?", (topic,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@@ -984,14 +914,6 @@ async def kanban_members_of(topic: str, group_id: str) -> list[str]:
|
|||||||
return [r[0] for r in await cursor.fetchall()]
|
return [r[0] for r in await cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
async def kanban_member_group(topic: str, member_id: str) -> str | None:
|
|
||||||
db = await get_db()
|
|
||||||
cursor = await db.execute(
|
|
||||||
"SELECT group_id FROM kanban_members WHERE topic = ? AND member_id = ?", (topic, member_id))
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
return row[0] if row else None
|
|
||||||
|
|
||||||
|
|
||||||
# ── Guide board (one card per block, linear stages) ──────────────────────────────
|
# ── Guide board (one card per block, linear stages) ──────────────────────────────
|
||||||
async def upsert_guide_card(topic: str, format: str, block_norm: str, block: str,
|
async def upsert_guide_card(topic: str, format: str, block_norm: str, block: str,
|
||||||
stage: str = "lernziele") -> None:
|
stage: str = "lernziele") -> None:
|
||||||
@@ -1277,13 +1199,6 @@ async def mark_sources_read_done(topic: str, sources: list[str]) -> None:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
async def list_coverage(topic: str) -> dict[str, int]:
|
|
||||||
db = await get_db()
|
|
||||||
cursor = await db.execute("SELECT source, read_done FROM research_coverage WHERE topic = ?", (topic,))
|
|
||||||
rows = await cursor.fetchall()
|
|
||||||
return {q: g for q, g in rows}
|
|
||||||
|
|
||||||
|
|
||||||
async def mark_content(topic: str, content: list[str], noise: list[str]) -> None:
|
async def mark_content(topic: str, content: list[str], noise: list[str]) -> None:
|
||||||
"""Store the triage result per crawl page: content=1 (content) or 0 (noise)."""
|
"""Store the triage result per crawl page: content=1 (content) or 0 (noise)."""
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
@@ -1335,16 +1250,6 @@ async def get_step_status(topic: str, step: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_pipeline_state(topic: str, steps: list[str] | None = None) -> None:
|
|
||||||
db = await get_db()
|
|
||||||
if steps is None:
|
|
||||||
await db.execute("DELETE FROM pipeline_state WHERE topic = ?", (topic,))
|
|
||||||
elif steps:
|
|
||||||
marks = ",".join("?" for _ in steps)
|
|
||||||
await db.execute(f"DELETE FROM pipeline_state WHERE topic = ? AND step IN ({marks})", (topic, *steps))
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -90,10 +90,6 @@ class BlocksStatusResponse(BaseModel):
|
|||||||
feine_steps: list[BlocksFineStep] = []
|
feine_steps: list[BlocksFineStep] = []
|
||||||
|
|
||||||
|
|
||||||
class ProjectResponse(BaseModel):
|
|
||||||
name: str
|
|
||||||
|
|
||||||
|
|
||||||
class FolderResponse(BaseModel):
|
class FolderResponse(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
location: str # path relative to the repo root (e.g. "projects/foo")
|
location: str # path relative to the repo root (e.g. "projects/foo")
|
||||||
@@ -158,15 +154,6 @@ class GuideChatResponse(BaseModel):
|
|||||||
reply: str
|
reply: str
|
||||||
|
|
||||||
|
|
||||||
class ProgressUpdate(BaseModel):
|
|
||||||
chapter: str = Field(min_length=1, max_length=100)
|
|
||||||
done: bool
|
|
||||||
|
|
||||||
|
|
||||||
class ProgressResponse(BaseModel):
|
|
||||||
chapters: list[str]
|
|
||||||
|
|
||||||
|
|
||||||
# --- Block learning ---
|
# --- Block learning ---
|
||||||
|
|
||||||
class BlockChatRequest(BaseModel):
|
class BlockChatRequest(BaseModel):
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from config import PROJECTS_DIR, UNI_DIR, PROVIDERS
|
|||||||
from database import (
|
from database import (
|
||||||
create_guide, delete_guide, get_guide, list_guides,
|
create_guide, delete_guide, get_guide, list_guides,
|
||||||
create_topic, list_topics as db_list_topics, delete_topic,
|
create_topic, list_topics as db_list_topics, delete_topic,
|
||||||
list_progress, set_progress, delete_progress,
|
|
||||||
list_block_progress, get_block_progress, set_open_question,
|
list_block_progress, get_block_progress, set_open_question,
|
||||||
set_block_score_and_streak,
|
set_block_score_and_streak,
|
||||||
delete_block_data, delete_block_progress, subs_per_level, subs_per_level_raw,
|
delete_block_data, delete_block_progress, subs_per_level, subs_per_level_raw,
|
||||||
@@ -33,7 +32,7 @@ from models import (
|
|||||||
BlocksCreateRequest, BlocksResetStageRequest, BlocksCardRestartRequest, BlocksStatusResponse,
|
BlocksCreateRequest, BlocksResetStageRequest, BlocksCardRestartRequest, BlocksStatusResponse,
|
||||||
GuideCardResetRequest, GuideFormatRequest,
|
GuideCardResetRequest, GuideFormatRequest,
|
||||||
GuideBoardResetRequest, GuideChatRequest, GuideChatResponse,
|
GuideBoardResetRequest, GuideChatRequest, GuideChatResponse,
|
||||||
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
ProviderInfo,
|
||||||
FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview,
|
FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview,
|
||||||
BlockChatRequest, BlockChatResponse,
|
BlockChatRequest, BlockChatResponse,
|
||||||
BlockExamRequest, BlockExamResponse, BlockLearnStateResponse,
|
BlockExamRequest, BlockExamResponse, BlockLearnStateResponse,
|
||||||
@@ -65,19 +64,19 @@ async def get_topics():
|
|||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
async def get_stats():
|
async def get_stats():
|
||||||
"""Tracker: number of topics + per format created/completed."""
|
"""Tracker: number of topics + per format created/completed."""
|
||||||
guides, progress, levels = await load_learnstate()
|
guides, levels = await load_learnstate()
|
||||||
topics = set(await db_list_topics()) | {g["topic"] for g in guides} | set(blocks_topics())
|
topics = set(await db_list_topics()) | {g["topic"] for g in guides} | set(blocks_topics())
|
||||||
if PROJECTS_DIR.is_dir():
|
if PROJECTS_DIR.is_dir():
|
||||||
topics |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()}
|
topics |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()}
|
||||||
return {"topics": len(topics), "formats": formats_stats(guides, progress, levels)}
|
return {"topics": len(topics), "formats": formats_stats(guides, levels)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/topics/progress")
|
@router.get("/topics/progress")
|
||||||
async def topic_progress(topic: str):
|
async def topic_progress(topic: str):
|
||||||
"""Completion status per format + topic completion — for unlocking the next expansion stage."""
|
"""Completion status per format + topic completion — for unlocking the next expansion stage."""
|
||||||
guides, progress, levels = await load_learnstate()
|
guides, levels = await load_learnstate()
|
||||||
status = {fmt: ist_completed(topic, fmt, guides, progress, levels) for fmt in FORMATE}
|
status = {fmt: ist_completed(topic, fmt, guides, levels) for fmt in FORMATE}
|
||||||
status["completed"] = topic_completed(topic, guides, progress, levels)
|
status["completed"] = topic_completed(topic, guides, levels)
|
||||||
return status
|
return status
|
||||||
|
|
||||||
|
|
||||||
@@ -98,29 +97,6 @@ async def remove_topic(topic: str):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
def _safe_project_name(name: str) -> str:
|
|
||||||
if not name or "/" in name or "\\" in name or ".." in name or "\x00" in name:
|
|
||||||
raise HTTPException(400, "Invalid project name")
|
|
||||||
return name
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/projects", response_model=list[ProjectResponse])
|
|
||||||
async def list_projects():
|
|
||||||
if not PROJECTS_DIR.is_dir():
|
|
||||||
return []
|
|
||||||
return [{"name": entry.name} for entry in sorted(PROJECTS_DIR.iterdir()) if entry.is_dir()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/projects/{name}")
|
|
||||||
async def remove_project(name: str):
|
|
||||||
_safe_project_name(name)
|
|
||||||
pdir = project_dir(name)
|
|
||||||
if not pdir.is_dir():
|
|
||||||
raise HTTPException(404, "Project not found")
|
|
||||||
shutil.rmtree(pdir)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/folders", response_model=list[FolderResponse])
|
@router.get("/folders", response_model=list[FolderResponse])
|
||||||
async def list_folders(kind: str):
|
async def list_folders(kind: str):
|
||||||
"""Folders for the source selection: kind=projekt → projects/, kind=uni → uni/."""
|
"""Folders for the source selection: kind=projekt → projects/, kind=uni → uni/."""
|
||||||
@@ -181,12 +157,6 @@ async def get_blocks_board(topic: str):
|
|||||||
return snap
|
return snap
|
||||||
|
|
||||||
|
|
||||||
@router.get("/blocks/agents")
|
|
||||||
async def get_blocks_agents(topic: str):
|
|
||||||
return [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(f"blocks-{topic}-"), "runtime": a["runtime"]}
|
|
||||||
for a in active_agents(f"blocks-{topic}-")]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/blocks/research")
|
@router.post("/blocks/research")
|
||||||
async def add_blocks_research(topic: str, provider: str = "claude"):
|
async def add_blocks_research(topic: str, provider: str = "claude"):
|
||||||
"""Attach one more research agent — to the live flow, or attach-or-start."""
|
"""Attach one more research agent — to the live flow, or attach-or-start."""
|
||||||
@@ -335,24 +305,6 @@ async def get_question_pattern(topic: str, block: str):
|
|||||||
return {"pattern": await load_question_pattern_free(topic, block, fe)}
|
return {"pattern": await load_question_pattern_free(topic, block, fe)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/blocks/artefakte")
|
|
||||||
async def get_artefakte(topic: str, type: str | None = None):
|
|
||||||
"""Learning artifacts (flashcards/examples) per topic, grouped by block norm — per subblock."""
|
|
||||||
rows = await get_sub_artefakte(topic, type)
|
|
||||||
out: dict[str, dict] = {}
|
|
||||||
for r in rows:
|
|
||||||
b = out.setdefault(r["block_norm"], {"block": r["block"], "flashcard": [], "example": []})
|
|
||||||
if r["block"] and not b["block"]:
|
|
||||||
b["block"] = r["block"]
|
|
||||||
try:
|
|
||||||
data = json.loads(r["data"])
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
continue
|
|
||||||
if r["type"] in ("flashcard", "example"):
|
|
||||||
b[r["type"]].append({"subblock": r["sub_title"], **data})
|
|
||||||
return {"artefakte": out}
|
|
||||||
|
|
||||||
|
|
||||||
# --- Practice deck: Leitner flashcard pool per topic ---
|
# --- Practice deck: Leitner flashcard pool per topic ---
|
||||||
|
|
||||||
async def build_practice_deck(topic: str) -> dict:
|
async def build_practice_deck(topic: str) -> dict:
|
||||||
@@ -627,8 +579,8 @@ async def block_exam_route(req: BlockExamRequest):
|
|||||||
|
|
||||||
@router.post("/guides", response_model=GuideResponse)
|
@router.post("/guides", response_model=GuideResponse)
|
||||||
async def create(req: GuideCreateRequest):
|
async def create(req: GuideCreateRequest):
|
||||||
guides, progress, levels = await load_learnstate()
|
guides, levels = await load_learnstate()
|
||||||
reason = guide_lock(req.topic.strip(), req.format, guides, progress, levels)
|
reason = guide_lock(req.topic.strip(), req.format, guides, levels)
|
||||||
if reason:
|
if reason:
|
||||||
raise HTTPException(400 if reason == "Erst Blocks erstellen" else 409, reason) # string matches rules.py contract
|
raise HTTPException(400 if reason == "Erst Blocks erstellen" else 409, reason) # string matches rules.py contract
|
||||||
await create_topic(req.topic.strip())
|
await create_topic(req.topic.strip())
|
||||||
@@ -653,27 +605,6 @@ async def list_all():
|
|||||||
return await list_guides()
|
return await list_guides()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/guides/locks")
|
|
||||||
async def guide_locks(topic: str):
|
|
||||||
"""Lock reasons per format for the ▶ button — None = creatable."""
|
|
||||||
guides, progress, levels = await load_learnstate()
|
|
||||||
return {fmt: guide_lock(topic, fmt, guides, progress, levels) for fmt in ("FullGuide", "Rest", *FORMATE)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/guides/steps")
|
|
||||||
async def guide_steps(topic: str):
|
|
||||||
"""Highest fully completed stage index per format (card-based, -1 = none).
|
|
||||||
Content file present (legacy without cards) → everything done."""
|
|
||||||
import guide_board
|
|
||||||
out = {}
|
|
||||||
for fmt in ("Guide", "FullGuide", "Rest"):
|
|
||||||
step = await guide_board.done_step(topic, fmt)
|
|
||||||
if step < 0 and guide_content_path(topic, fmt).exists():
|
|
||||||
step = len(guide_board.GUIDE_STAGES)
|
|
||||||
out[fmt] = step
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/guides/board")
|
@router.get("/guides/board")
|
||||||
async def get_guide_board(topic: str, format: str = "Guide"):
|
async def get_guide_board(topic: str, format: str = "Guide"):
|
||||||
"""Live guide board: columns with counts + cards (rounds, covered objectives), agents."""
|
"""Live guide board: columns with counts + cards (rounds, covered objectives), agents."""
|
||||||
@@ -704,14 +635,6 @@ async def reset_guide_board(req: GuideBoardResetRequest):
|
|||||||
return {"ok": True, "moved": moved}
|
return {"ok": True, "moved": moved}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/guides/{guide_id}", response_model=GuideResponse)
|
|
||||||
async def get_one(guide_id: str):
|
|
||||||
guide = await get_guide(guide_id)
|
|
||||||
if guide is None:
|
|
||||||
raise HTTPException(404, "Guide not found")
|
|
||||||
return guide
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/guides/{guide_id}/content")
|
@router.get("/guides/{guide_id}/content")
|
||||||
async def guide_content(guide_id: str, level: int = 4):
|
async def guide_content(guide_id: str, level: int = 4):
|
||||||
"""Guide content. `level` (1=A · 2=F · 3=E · 4=V) filters to subblocks up to this
|
"""Guide content. `level` (1=A · 2=F · 3=E · 4=V) filters to subblocks up to this
|
||||||
@@ -789,7 +712,6 @@ async def remove_guide_format(req: GuideFormatRequest):
|
|||||||
if any(g["status"] in ("queued", "generating") for g in doomed):
|
if any(g["status"] in ("queued", "generating") for g in doomed):
|
||||||
return {"ok": True, "status": "generating"}
|
return {"ok": True, "status": "generating"}
|
||||||
for g in doomed:
|
for g in doomed:
|
||||||
await delete_progress(g["id"])
|
|
||||||
await delete_guide(g["id"])
|
await delete_guide(g["id"])
|
||||||
await delete_guide_content(req.topic, req.format)
|
await delete_guide_content(req.topic, req.format)
|
||||||
await delete_guide_board(req.topic, req.format)
|
await delete_guide_board(req.topic, req.format)
|
||||||
@@ -805,7 +727,6 @@ async def remove(guide_id: str, slots: bool = False):
|
|||||||
guide = await get_guide(guide_id)
|
guide = await get_guide(guide_id)
|
||||||
if guide is None:
|
if guide is None:
|
||||||
raise HTTPException(404, "Guide not found")
|
raise HTTPException(404, "Guide not found")
|
||||||
await delete_progress(guide_id)
|
|
||||||
await delete_guide(guide_id)
|
await delete_guide(guide_id)
|
||||||
# Content/step files are shared by all runs of a topic+format — only delete them
|
# Content/step files are shared by all runs of a topic+format — only delete them
|
||||||
# once no entry needs them anymore. Partial progress (step files without finished
|
# once no entry needs them anymore. Partial progress (step files without finished
|
||||||
@@ -820,20 +741,3 @@ async def remove(guide_id: str, slots: bool = False):
|
|||||||
p.unlink(missing_ok=True)
|
p.unlink(missing_ok=True)
|
||||||
content.unlink(missing_ok=True)
|
content.unlink(missing_ok=True)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/guides/{guide_id}/progress", response_model=ProgressResponse)
|
|
||||||
async def get_progress(guide_id: str):
|
|
||||||
guide = await get_guide(guide_id)
|
|
||||||
if guide is None:
|
|
||||||
raise HTTPException(404, "Guide not found")
|
|
||||||
return {"chapters": await list_progress(guide_id)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/guides/{guide_id}/progress", response_model=ProgressResponse)
|
|
||||||
async def update_progress(guide_id: str, req: ProgressUpdate):
|
|
||||||
guide = await get_guide(guide_id)
|
|
||||||
if guide is None:
|
|
||||||
raise HTTPException(404, "Guide not found")
|
|
||||||
await set_progress(guide_id, req.chapter, req.done)
|
|
||||||
return {"chapters": await list_progress(guide_id)}
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ query loops per guide.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from database import list_block_scores_all, subs_per_level_all, list_guides, list_progress_all
|
from database import list_block_scores_all, subs_per_level_all, list_guides
|
||||||
from guide import guide_slot_files
|
from guide import guide_slot_files
|
||||||
from learning import cap_final, LEVELS, _threshold
|
from learning import cap_final, LEVELS, _threshold
|
||||||
from paths import blocks_path, guide_content_path
|
from paths import blocks_path, guide_content_path
|
||||||
@@ -33,8 +33,8 @@ _LEVEL_WORT = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def load_learnstate() -> tuple[list[dict], dict[str, set[str]], dict[str, dict[str, set[str]]]]:
|
async def load_learnstate() -> tuple[list[dict], dict[str, dict[str, set[str]]]]:
|
||||||
"""Guides + chapter progress + blocks per level.
|
"""Guides + blocks per level.
|
||||||
|
|
||||||
levels: {"beginner"/"advanced"/"expert"/"master": {topic → normalized title}}.
|
levels: {"beginner"/"advanced"/"expert"/"master": {topic → normalized title}}.
|
||||||
The level per block is derived from score + cap (4×relevant subs).
|
The level per block is derived from score + cap (4×relevant subs).
|
||||||
@@ -47,7 +47,7 @@ async def load_learnstate() -> tuple[list[dict], dict[str, set[str]], dict[str,
|
|||||||
for key, p in LEVELS:
|
for key, p in LEVELS:
|
||||||
if cf and score >= _threshold(p, cf):
|
if cf and score >= _threshold(p, cf):
|
||||||
levels[key].setdefault(topic, set()).add(_norm_title(block))
|
levels[key].setdefault(topic, set()).add(_norm_title(block))
|
||||||
return await list_guides(), await list_progress_all(), levels
|
return await list_guides(), levels
|
||||||
|
|
||||||
|
|
||||||
def _content_json(topic: str, fmt: str) -> dict | None:
|
def _content_json(topic: str, fmt: str) -> dict | None:
|
||||||
@@ -84,39 +84,39 @@ def _latest_done(guides: list[dict], fmt: str) -> dict[str, dict]:
|
|||||||
return latest
|
return latest
|
||||||
|
|
||||||
|
|
||||||
def _guide_all(g: dict, progress: dict[str, set[str]], levelset: dict[str, set[str]]) -> bool:
|
def _guide_all(g: dict, levelset: dict[str, set[str]]) -> bool:
|
||||||
"""Are ALL blocks of the guide at the required level?"""
|
"""Are ALL blocks of the guide at the required level?"""
|
||||||
sections = _section_title(g["topic"], g["format"])
|
sections = _section_title(g["topic"], g["format"])
|
||||||
return bool(sections) and sections <= levelset.get(g["topic"], set())
|
return bool(sections) and sections <= levelset.get(g["topic"], set())
|
||||||
|
|
||||||
|
|
||||||
def is_level(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], levelset: dict[str, set[str]]) -> bool:
|
def is_level(topic: str, fmt: str, guides: list[dict], levelset: dict[str, set[str]]) -> bool:
|
||||||
"""Latest finished guide (topic+format): all blocks at the level of levelset?"""
|
"""Latest finished guide (topic+format): all blocks at the level of levelset?"""
|
||||||
g = _latest_done(guides, fmt).get(topic)
|
g = _latest_done(guides, fmt).get(topic)
|
||||||
return g is not None and _guide_all(g, progress, levelset)
|
return g is not None and _guide_all(g, levelset)
|
||||||
|
|
||||||
|
|
||||||
def ist_completed(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> bool:
|
def ist_completed(topic: str, fmt: str, guides: list[dict], levels: dict[str, dict[str, set[str]]]) -> bool:
|
||||||
"""All blocks of the latest finished guide at least beginner (≥20%)?"""
|
"""All blocks of the latest finished guide at least beginner (≥20%)?"""
|
||||||
return is_level(topic, fmt, guides, progress, levels["beginner"])
|
return is_level(topic, fmt, guides, levels["beginner"])
|
||||||
|
|
||||||
|
|
||||||
def topic_completed(topic: str, guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> bool:
|
def topic_completed(topic: str, guides: list[dict], levels: dict[str, dict[str, set[str]]]) -> bool:
|
||||||
"""Topic done: latest finished guide, all blocks at master (100%)?"""
|
"""Topic done: latest finished guide, all blocks at master (100%)?"""
|
||||||
return is_level(topic, "Guide", guides, progress, levels["master"])
|
return is_level(topic, "Guide", guides, levels["master"])
|
||||||
|
|
||||||
|
|
||||||
def formats_stats(guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> dict:
|
def formats_stats(guides: list[dict], levels: dict[str, dict[str, set[str]]]) -> dict:
|
||||||
"""Per format created/completed — per topic only the latest finished guide counts."""
|
"""Per format created/completed — per topic only the latest finished guide counts."""
|
||||||
formats = {}
|
formats = {}
|
||||||
for fmt in FORMATE:
|
for fmt in FORMATE:
|
||||||
latest = _latest_done(guides, fmt)
|
latest = _latest_done(guides, fmt)
|
||||||
completed = sum(1 for g in latest.values() if _guide_all(g, progress, levels["beginner"]))
|
completed = sum(1 for g in latest.values() if _guide_all(g, levels["beginner"]))
|
||||||
formats[fmt] = {"created": len(latest), "completed": completed}
|
formats[fmt] = {"created": len(latest), "completed": completed}
|
||||||
return formats
|
return formats
|
||||||
|
|
||||||
|
|
||||||
def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> str | None:
|
def guide_lock(topic: str, fmt: str, guides: list[dict], levels: dict[str, dict[str, set[str]]]) -> str | None:
|
||||||
"""Reason why a fresh start for topic+format is locked — None = allowed.
|
"""Reason why a fresh start for topic+format is locked — None = allowed.
|
||||||
|
|
||||||
Exactly the rules from POST /guides: blocks required, no duplicate start,
|
Exactly the rules from POST /guides: blocks required, no duplicate start,
|
||||||
@@ -132,9 +132,9 @@ def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set
|
|||||||
prereq = PRESTAGE.get(fmt)
|
prereq = PRESTAGE.get(fmt)
|
||||||
if prereq:
|
if prereq:
|
||||||
level = FREISCHALT_LEVEL[fmt] # completed=10 · understood=20 · mastered=30
|
level = FREISCHALT_LEVEL[fmt] # completed=10 · understood=20 · mastered=30
|
||||||
if not is_level(topic, prereq, guides, progress, levels[level]):
|
if not is_level(topic, prereq, guides, levels[level]):
|
||||||
return f"First take the {prereq} of this topic {_LEVEL_WORT[level]}"
|
return f"First take the {prereq} of this topic {_LEVEL_WORT[level]}"
|
||||||
stat = formats_stats(guides, progress, levels).get(fmt, {"created": 0, "completed": 0})
|
stat = formats_stats(guides, levels).get(fmt, {"created": 0, "completed": 0})
|
||||||
open_count = stat["created"] - stat["completed"]
|
open_count = stat["created"] - stat["completed"]
|
||||||
if open_count >= MAX_OFFENE_GUIDES:
|
if open_count >= MAX_OFFENE_GUIDES:
|
||||||
return f"Complete {fmt}s first — at most {MAX_OFFENE_GUIDES} open allowed ({open_count} open)"
|
return f"Complete {fmt}s first — at most {MAX_OFFENE_GUIDES} open allowed ({open_count} open)"
|
||||||
|
|||||||
@@ -764,3 +764,78 @@ def test_per_block_functions_accept_wrapper_kwargs():
|
|||||||
assert "seeds" in inspect.signature(blx._subblocks_block).parameters
|
assert "seeds" in inspect.signature(blx._subblocks_block).parameters
|
||||||
for fn in ("_subblocks_block", "_facts_block"): # Board 2 reicht die Block-Quellen durch
|
for fn in ("_subblocks_block", "_facts_block"): # Board 2 reicht die Block-Quellen durch
|
||||||
assert "sources" in inspect.signature(getattr(blx, fn)).parameters, fn
|
assert "sources" in inspect.signature(getattr(blx, fn)).parameters, fn
|
||||||
|
|
||||||
|
|
||||||
|
# ── Inventar-Härtung: Sanitizer, Akronym-Regel, Supplement-Beleg ─────────────────────
|
||||||
|
|
||||||
|
def test_clean_title_strips_markdown_only():
|
||||||
|
"""`**` und Backticks fliegen; Math-Zeichen (|, _, einzelnes *) bleiben."""
|
||||||
|
from textkit import clean_title
|
||||||
|
assert clean_title("**ListScheduling**") == "ListScheduling"
|
||||||
|
assert clean_title("**LPT** - Algo") == "LPT - Algo"
|
||||||
|
assert clean_title("`code` doppelt") == "code doppelt"
|
||||||
|
assert clean_title("2|prec, pi∈{1,2}|Cmax") == "2|prec, pi∈{1,2}|Cmax"
|
||||||
|
assert clean_title("x_i und P*") == "x_i und P*"
|
||||||
|
|
||||||
|
|
||||||
|
def test_title_variants_acronym_expansion():
|
||||||
|
from blocks import _title_variants
|
||||||
|
assert _title_variants("Satisfiability Problem (SAT)") == {"satisfiability problem", "sat"}
|
||||||
|
assert _title_variants("DEA (Deterministischer Endlicher Automat)") == {
|
||||||
|
"dea", "deterministischer endlicher automat"}
|
||||||
|
assert _title_variants("SAT") == set()
|
||||||
|
assert _title_variants("2|prec, pi∈{1,2}|Cmax") == set()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dedup_acronym_pair_judged(board_env, tmp_path, monkeypatch, emb_on):
|
||||||
|
"""Kurzform vs. Langform liegt unterm Embedding-Floor (real: Cos 0.53) — die
|
||||||
|
Akronym-Regel macht das Paar trotzdem zum Kandidaten, das Panel merged bei 2× ja."""
|
||||||
|
monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({})) # alles orthogonal
|
||||||
|
counter = {}
|
||||||
|
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
|
||||||
|
("-dedup-", {"pairs": {"1": "ja"}}),
|
||||||
|
], counter))
|
||||||
|
await _run_dedup(board_env[0], board_env[1], tmp_path, [
|
||||||
|
("b-1", {"title": "SAT", "description": "kurz"}),
|
||||||
|
("b-2", {"title": "Satisfiability Problem (SAT)", "description": "lang"}),
|
||||||
|
])
|
||||||
|
stages = sorted([(await board_env[0].kanban_get_card(TOPIC, B, c))["stage"]
|
||||||
|
for c in ("b-1", "b-2")])
|
||||||
|
assert stages == ["grouped", "grouping"]
|
||||||
|
assert counter["-dedup-"] == 2 # Panel lief — kein Auto-Merge
|
||||||
|
|
||||||
|
|
||||||
|
async def test_supplement_beleg_gate(board_env, tmp_path, monkeypatch):
|
||||||
|
"""Ohne Auszugs-Treffer fällt ein Vorschlag sofort; der Judge verwirft „nein";
|
||||||
|
nur belegte Vorschläge überleben. Antwort ist Text (no-tool), Engine persistiert."""
|
||||||
|
db, ctx, files = board_env
|
||||||
|
korpus = tmp_path / "korpus"
|
||||||
|
korpus.mkdir()
|
||||||
|
(korpus / "Skript.txt").write_text(
|
||||||
|
"Kapitel 1\nVertex Cover Definition und Übung dazu.\nMatching Grundlagen kurz.\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
monkeypatch.setattr(bi, "source_folder", lambda t: korpus)
|
||||||
|
calls = {}
|
||||||
|
|
||||||
|
async def fake_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||||
|
calls["key"], calls["caps"], calls["prompt"] = key, capabilities, prompt
|
||||||
|
return bi.OK, payload((0, '{"relevant": {"1": "ja", "2": "nein"}}', ""))
|
||||||
|
|
||||||
|
monkeypatch.setattr(bi, "run_single_slot", fake_slot)
|
||||||
|
kept = await bi._supplement_beleg(ctx, _mk_flow(tmp_path), [
|
||||||
|
("Vertex Cover", "Knotenüberdeckung"),
|
||||||
|
("Matching", "Paarung"),
|
||||||
|
("Quantencomputer", "nicht im Material"),
|
||||||
|
])
|
||||||
|
assert kept == [("Vertex Cover", "Knotenüberdeckung")]
|
||||||
|
assert calls["caps"] == "none" and "-supplement-beleg" in calls["key"]
|
||||||
|
assert "Quantencomputer" not in calls["prompt"] # fiel schon am Auszugs-Filter
|
||||||
|
assert (tmp_path / "supplement-beleg.json").exists() # Resume-Guard
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ingest_strips_markdown_title(testdb, tmp_path):
|
||||||
|
flow = _mk_flow(tmp_path)
|
||||||
|
n = await bi._ingest_titles(flow, "1. **ListScheduling** — Greedy-Verfahren", "r1")
|
||||||
|
assert n == 1
|
||||||
|
card = await testdb.kanban_get_card(TOPIC, B, "listscheduling")
|
||||||
|
assert card["payload"]["title"] == "ListScheduling"
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ async def test_learnstate_smoke(testdb):
|
|||||||
"""Regression: P5-Ausbau hatte die _LEVEL_CASE-Konstante mitgerissen —
|
"""Regression: P5-Ausbau hatte die _LEVEL_CASE-Konstante mitgerissen —
|
||||||
load_learnstate (Guide-Start-Pfad) muss ohne NameError laufen."""
|
load_learnstate (Guide-Start-Pfad) muss ohne NameError laufen."""
|
||||||
from rules import load_learnstate
|
from rules import load_learnstate
|
||||||
guides, progress, levels = await load_learnstate()
|
guides, levels = await load_learnstate()
|
||||||
assert isinstance(levels, dict)
|
assert isinstance(levels, dict)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ def _title(entry: str) -> str:
|
|||||||
return entry.split(" — ")[0].strip() or entry
|
return entry.split(" — ")[0].strip() or entry
|
||||||
|
|
||||||
|
|
||||||
|
def clean_title(s: str) -> str:
|
||||||
|
"""Strip markdown noise from a DISPLAY title (norm keys use _norm_title).
|
||||||
|
Only clearly-markdown characters go: `**` pairs and backticks. Single `*`,
|
||||||
|
underscores and pipes stay — they are legitimate in math titles
|
||||||
|
(„2|prec, pi∈{1,2}|Cmax", „x_i", „P*")."""
|
||||||
|
s = (s or "").replace("**", "").replace("`", "")
|
||||||
|
return re.sub(r"\s+", " ", s).strip()
|
||||||
|
|
||||||
|
|
||||||
def _unique_title(entries: dict[int, str]) -> dict[int, str]:
|
def _unique_title(entries: dict[int, str]) -> dict[int, str]:
|
||||||
"""Make titles unique (suffix " (2)", " (3)" …) so they work as keys."""
|
"""Make titles unique (suffix " (2)", " (3)" …) so they work as keys."""
|
||||||
seen: dict[str, int] = {}
|
seen: dict[str, int] = {}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, resetBlocksStage as apiResetBlocksStage, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, resetGuideBoard as apiResetGuideBoard, restartBlocksCard as apiRestartBlocksCard, resetGuideCard as apiResetGuideCard, removeGuideFormat as apiRemoveGuideFormat, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchGuideLocks, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
|
import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, resetBlocksStage as apiResetBlocksStage, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, resetGuideBoard as apiResetGuideBoard, restartBlocksCard as apiRestartBlocksCard, resetGuideCard as apiResetGuideCard, removeGuideFormat as apiRemoveGuideFormat, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
|
||||||
import { usePolling } from './composables/usePolling.js'
|
import { usePolling } from './composables/usePolling.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'
|
||||||
@@ -34,7 +34,6 @@ const _storedLevel = localStorage.getItem('level')
|
|||||||
const levelView = ref(_storedLevel === 'auto' || !_storedLevel ? 'auto' : Number(_storedLevel) || 'auto') // 'auto' | 1-4
|
const levelView = ref(_storedLevel === 'auto' || !_storedLevel ? 'auto' : Number(_storedLevel) || 'auto') // 'auto' | 1-4
|
||||||
const stats = ref(null)
|
const stats = ref(null)
|
||||||
const progress = ref({})
|
const progress = ref({})
|
||||||
const locks = ref({}) // lock reasons per format (backend = single rule source)
|
|
||||||
const uiError = ref(null) // surface rejected actions (409/400)
|
const uiError = ref(null) // surface rejected actions (409/400)
|
||||||
|
|
||||||
// Run a loader, log + swallow its error (a failed background load must not break the UI).
|
// Run a loader, log + swallow its error (a failed background load must not break the UI).
|
||||||
@@ -166,11 +165,9 @@ async function loadBlocks() {
|
|||||||
if (selectedTopic.value) {
|
if (selectedTopic.value) {
|
||||||
blocks.value = await fetchBlocksStatus(selectedTopic.value)
|
blocks.value = await fetchBlocksStatus(selectedTopic.value)
|
||||||
progress.value = await fetchTopicProgress(selectedTopic.value)
|
progress.value = await fetchTopicProgress(selectedTopic.value)
|
||||||
locks.value = await fetchGuideLocks(selectedTopic.value)
|
|
||||||
} else {
|
} else {
|
||||||
blocks.value = { ...EMPTY_BLOCKS }
|
blocks.value = { ...EMPTY_BLOCKS }
|
||||||
progress.value = {}
|
progress.value = {}
|
||||||
locks.value = {}
|
|
||||||
}
|
}
|
||||||
if (activeBlocks.value.length && !polling.running()) startPolling()
|
if (activeBlocks.value.length && !polling.running()) startPolling()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -449,7 +446,6 @@ onMounted(async () => {
|
|||||||
:selectedTopic="selectedTopic"
|
:selectedTopic="selectedTopic"
|
||||||
:stats="stats"
|
:stats="stats"
|
||||||
:fortschritt="progress"
|
:fortschritt="progress"
|
||||||
:locks="locks"
|
|
||||||
:uiError="uiError"
|
:uiError="uiError"
|
||||||
:doneByFormat="doneByFormat"
|
:doneByFormat="doneByFormat"
|
||||||
:latestByFormat="latestByFormat"
|
:latestByFormat="latestByFormat"
|
||||||
@@ -533,9 +529,7 @@ onMounted(async () => {
|
|||||||
<TopicDetail
|
<TopicDetail
|
||||||
v-else-if="selectedTopic"
|
v-else-if="selectedTopic"
|
||||||
:previewGuide="previewGuide"
|
:previewGuide="previewGuide"
|
||||||
:dark="darkMode"
|
|
||||||
:provider="provider"
|
:provider="provider"
|
||||||
:doneByFormat="doneByFormat"
|
|
||||||
:themaAbgeschlossen="!!progress.completed"
|
:themaAbgeschlossen="!!progress.completed"
|
||||||
:ansichtModus="viewMode"
|
:ansichtModus="viewMode"
|
||||||
:stufeAnsicht="levelView"
|
:stufeAnsicht="levelView"
|
||||||
|
|||||||
@@ -18,16 +18,6 @@ export async function fetchGuides() {
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchGuideSteps(topic) {
|
|
||||||
const res = await fetch(`${BASE}/guides/steps?topic=${encodeURIComponent(topic)}`)
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchGuideLocks(topic) {
|
|
||||||
const res = await fetch(`${BASE}/guides/locks?topic=${encodeURIComponent(topic)}`)
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createGuide(topic, format, instructions = '', provider = 'claude', abStep = null) {
|
export async function createGuide(topic, format, instructions = '', provider = 'claude', abStep = null) {
|
||||||
const res = await fetch(`${BASE}/guides`, {
|
const res = await fetch(`${BASE}/guides`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import BlockPanel from './BlockPanel.vue'
|
import BlockPanel from './BlockPanel.vue'
|
||||||
import { renderMarkdown, renderBlocks } from '../markdown.js'
|
import { renderMarkdown, renderBlocks } from '../markdown.js'
|
||||||
import { stufeFuer, LEVELS } from '../levels.js'
|
import { stufeFuer } from '../levels.js'
|
||||||
import { pruefeBlock, uebernehmeBlock, resetBlockProgress } from '../api.js'
|
import { pruefeBlock, uebernehmeBlock, resetBlockProgress } from '../api.js'
|
||||||
import { clearPruef } from '../pruefungCache.js'
|
import { clearPruef } from '../pruefungCache.js'
|
||||||
import { useConfirm } from '../composables/useConfirm.js'
|
import { useConfirm } from '../composables/useConfirm.js'
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const props = defineProps({
|
|||||||
topic: { type: String, required: true },
|
topic: { type: String, required: true },
|
||||||
format: { type: String, default: 'Guide' },
|
format: { type: String, default: 'Guide' },
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['cancelGuide', 'startGuide', 'resetStage', 'preview', 'deleteGuide', 'resetCard'])
|
const emit = defineEmits(['cancelGuide', 'startGuide', 'resetStage', 'preview', 'removeFormat', 'resetCard'])
|
||||||
|
|
||||||
const board = ref(null)
|
const board = ref(null)
|
||||||
let timer = null
|
let timer = null
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import BlockFocus from './BlockFocus.vue'
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
previewGuide: { type: Object, default: null },
|
previewGuide: { type: Object, default: null },
|
||||||
dark: { type: Boolean, default: false },
|
|
||||||
provider: { type: String, default: 'claude' },
|
provider: { type: String, default: 'claude' },
|
||||||
doneByFormat: { type: Object, default: () => ({}) }, // format → finished guide (topic-related)
|
|
||||||
themaAbgeschlossen: { type: Boolean, default: false },
|
themaAbgeschlossen: { type: Boolean, default: false },
|
||||||
ansichtModus: { type: String, default: 'compact' }, // compact | erklärend
|
ansichtModus: { type: String, default: 'compact' }, // compact | erklärend
|
||||||
stufeAnsicht: { type: [Number, String], default: 'auto' }, // 'auto' | 1=A · 2=F · 3=E · 4=V
|
stufeAnsicht: { type: [Number, String], default: 'auto' }, // 'auto' | 1=A · 2=F · 3=E · 4=V
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ const props = defineProps({
|
|||||||
selectedTopic: { type: String, default: null },
|
selectedTopic: { type: String, default: null },
|
||||||
stats: { type: Object, default: null },
|
stats: { type: Object, default: null },
|
||||||
fortschritt: { type: Object, default: () => ({}) },
|
fortschritt: { type: Object, default: () => ({}) },
|
||||||
locks: { type: Object, default: () => ({}) },
|
|
||||||
uiError: { type: String, default: null },
|
uiError: { type: String, default: null },
|
||||||
doneByFormat: { type: Object, default: () => ({}) },
|
doneByFormat: { type: Object, default: () => ({}) },
|
||||||
latestByFormat: { type: Object, default: () => ({}) },
|
latestByFormat: { type: Object, default: () => ({}) },
|
||||||
|
|||||||
@@ -19,12 +19,6 @@ export function stufeFuer(score, cap) {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next not-yet-reached level (the goal) or null (= Master).
|
|
||||||
export function naechste(score, cap) {
|
|
||||||
for (const st of LEVELS) if (score < schwelle(st.floor, cap)) return st
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error-penalty display by progress (against cap_aktuell): ≤25%→−5 · ≤50%→−10 · ≤75%→−15 · >75%→−20.
|
// Error-penalty display by progress (against cap_aktuell): ≤25%→−5 · ≤50%→−10 · ≤75%→−15 · >75%→−20.
|
||||||
export function malusRegel(score, cap) {
|
export function malusRegel(score, cap) {
|
||||||
const pct = cap ? score / cap : 0
|
const pct = cap ? score / cap : 0
|
||||||
|
|||||||
@@ -73,10 +73,6 @@ export function renderMarkdownInline(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Strip markdown to plain text (code fences + inline marks) — for previews/search.
|
// Strip markdown to plain text (code fences + inline marks) — for previews/search.
|
||||||
export function plainText(text) {
|
|
||||||
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split markdown into top-level blocks: each block { raw (exact source), html }.
|
// Split markdown into top-level blocks: each block { raw (exact source), html }.
|
||||||
// raw is lossless (tokens.map(raw).join('') === original) → block-precise replacement.
|
// raw is lossless (tokens.map(raw).join('') === original) → block-precise replacement.
|
||||||
export function renderBlocks(text) {
|
export function renderBlocks(text) {
|
||||||
|
|||||||
14
templates/Prompt/Blocks-Supplement-Beleg.md
Normal file
14
templates/Prompt/Blocks-Supplement-Beleg.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
You are the material gate for supplement proposals on the topic "{topic}". A research agent proposed blocks that canonically belong to the subject — but the inventory must ONLY contain what the LEARNING MATERIAL itself covers. Decide per proposal: do the excerpts show the material actually TREATS this block → **ja**, or does the material never treat it → **nein**?
|
||||||
|
|
||||||
|
PROPOSALS (each with its best-matching material excerpts):
|
||||||
|
{proposals}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- **ja** requires real coverage: a heading, definition, algorithm, proof or exercise about the proposal's subject.
|
||||||
|
- Passing mentions, shared vocabulary or generic words (e.g. „Algorithmus" appears everywhere) are NOT coverage → **nein**.
|
||||||
|
- When in doubt → **nein**. The inventory already covers the material — supplements are a bonus, scope creep is not.
|
||||||
|
|
||||||
|
Reply with ONLY the JSON — no other text, no code fences.
|
||||||
|
Format (one verdict per number):
|
||||||
|
{{"relevant": {{"1": "ja", "2": "nein"}}}}
|
||||||
|
{extra}
|
||||||
@@ -7,6 +7,7 @@ EXISTING BLOCKS:
|
|||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Research the subject area (textbooks, standard references) and add ONLY blocks that canonically belong and are missing from the inventory.
|
- Research the subject area (textbooks, standard references) and add ONLY blocks that canonically belong and are missing from the inventory.
|
||||||
|
- The inventory mirrors a CONCRETE source (script/project). Propose only gaps the MATERIAL itself covers — no textbook canon beyond the source. Proposals without material coverage are discarded downstream.
|
||||||
- A block solves EXACTLY ONE PROBLEM and is ATOMIC — same standards as the inventory.
|
- A block solves EXACTLY ONE PROBLEM and is ATOMIC — same standards as the inventory.
|
||||||
- NO variants, rephrasings, or deep-dives of existing blocks — only genuine gaps.
|
- NO variants, rephrasings, or deep-dives of existing blocks — only genuine gaps.
|
||||||
- Invent nothing: only blocks you have backed by research.
|
- Invent nothing: only blocks you have backed by research.
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
You help adapt a learning element of a personal summary on the topic "{topic}". You change NOTHING directly — you propose changes, and the user confirms each one individually.
|
|
||||||
|
|
||||||
CURRENT ELEMENT (JSON):
|
|
||||||
{element_json}
|
|
||||||
|
|
||||||
CHAT TRANSCRIPT SO FAR:
|
|
||||||
{transcript}
|
|
||||||
|
|
||||||
Turn the latest user instruction into change proposals. Keep the element rules:
|
|
||||||
1. title — a concise title (max. 8 words, plain text, no Markdown/backticks)
|
|
||||||
2. description — what it is and what for: AT MOST 1–2 sentences
|
|
||||||
3. examples — SHORT and SIMPLE: the minimal example in a topic-appropriate format (a code block with language tag for code topics, otherwise example sentences/mini-dialogue/mini-scenario as plain text). Each with a variant label: a code comment (e.g. `<!-- Einzelner Absatz -->`) or a **bold** label (e.g. **Höfliche Bitte:**).
|
|
||||||
4. hints — every hint must be IMPORTANT or USEFUL. Telegraphic style: just the core statement. Example: "Keine Blockelemente in `<p>`."
|
|
||||||
|
|
||||||
Length: AS LONG AS NEEDED and AS SHORT AS POSSIBLE. Markdown: `inline-code` for identifiers, tags and commands — ALWAYS in backticks. Tone: clear German, direct, no filler sentences.
|
|
||||||
|
|
||||||
Each proposal:
|
|
||||||
- text: short, what is changed (max. 12 words, plain text)
|
|
||||||
- action: "remove" | "adjust" | "add"
|
|
||||||
- target: "title" | "description" | "examples" | "hints"
|
|
||||||
- index: 0-based position in the CURRENT examples or hints array (null for title/description and for "add")
|
|
||||||
- content: the new complete content (empty for "remove")
|
|
||||||
|
|
||||||
"remove" only for examples/hints. Only make proposals that the user instruction calls for.
|
|
||||||
|
|
||||||
Write the user-facing fields (`reply`, `text`, `content`) in GERMAN. Output ONLY valid JSON, no code fence, no other text:
|
|
||||||
{{"reply": "short reply to the user (1–2 sentences)", "changes": [{{"text": "...", "action": "adjust", "target": "hints", "index": 0, "content": "..."}}]}}
|
|
||||||
|
|
||||||
A pure question with no change request → answer it in reply, "changes": []
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
You research what information might still be missing from a learning element of a personal summary on the topic "{topic}".
|
|
||||||
|
|
||||||
CURRENT ELEMENT (JSON):
|
|
||||||
{element_json}
|
|
||||||
|
|
||||||
CONTEXT (excerpts from the topic material):
|
|
||||||
{context}
|
|
||||||
|
|
||||||
RESEARCH — gather candidates broadly: missing key points, important variants, common pitfalls, best practices. Better one candidate too many than one too few — the evaluation happens in a second step. Propose nothing the element already contains.
|
|
||||||
|
|
||||||
Each candidate:
|
|
||||||
- text: a short description of the gap (max. 12 words, plain text)
|
|
||||||
- target: "description" | "examples" | "hints"
|
|
||||||
- content: ready-to-insert content. AS SHORT AS POSSIBLE, as long as needed. Markdown: `inline-code` for identifiers; examples in a topic-appropriate format (a code block with language tag for code, otherwise example sentences/mini-scenario as plain text), each with a variant label (a code comment or a **bold** label); hints only if IMPORTANT or USEFUL, in telegraphic style (just the core statement, e.g. "Keine Blockelemente in `<p>`."). Tags/identifiers in running text ALWAYS in backticks.
|
|
||||||
|
|
||||||
Write the candidate fields (`text`, `content`) in GERMAN. Output ONLY valid JSON, no code fence, no other text:
|
|
||||||
{{"suggestions": [{{"text": "...", "target": "hints", "content": "..."}}]}}
|
|
||||||
|
|
||||||
No candidates → {{"suggestions": []}}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
You create a short learning element for a personal summary on the topic "{topic}".
|
|
||||||
|
|
||||||
KEYWORD FROM THE USER:
|
|
||||||
{hint}
|
|
||||||
|
|
||||||
CONTEXT (excerpts from the topic material):
|
|
||||||
{context}
|
|
||||||
|
|
||||||
Create EXACTLY ONE element for the keyword:
|
|
||||||
1. title — a concise title (max. 8 words, plain text, no Markdown/backticks)
|
|
||||||
2. description — what it is and what for: AT MOST 1–2 sentences
|
|
||||||
3. examples — EXACTLY ONE example: SHORT and SIMPLE, the minimal example in a topic-appropriate format (see EXAMPLE FORMAT), no real-world complexity.
|
|
||||||
4. hints — ALWAYS an empty list. The user adds hints later. (If ever required: TELEGRAPHIC style, max. 10 words.)
|
|
||||||
|
|
||||||
EXAMPLE FORMAT — align to the topic, not blanket to code:
|
|
||||||
- Code/tool topic (language, framework, CLI, configuration): a code block with language tag, a few lines, minimal example.
|
|
||||||
- Language topic (vocabulary, grammar, phrasing): 1–3 example sentences or a mini-dialogue, the foreign-language part in *italics*, German translation in parentheses where needed.
|
|
||||||
- Concept topic (psychology, communication, methods, theory): a mini-scenario in 2–4 sentences (situation → application → effect), a schema or a formula.
|
|
||||||
Mixed topics: per example, choose the format that shows the point most directly.
|
|
||||||
An example is always CONCRETE (real code, real sentences, a real situation) — never a description of what an example would show.
|
|
||||||
Each example names its variant: in code as a comment in the code syntax (e.g. `<!-- Einzelner Absatz -->`, `// Mit Default-Wert`), in prose as a leading **bold** label (e.g. **Höfliche Bitte:**).
|
|
||||||
|
|
||||||
The element is ATOMIC: understandable on its own, without the reader having read anything else. Resolve any terms used in a half-sentence.
|
|
||||||
|
|
||||||
Length: AS SHORT AS POSSIBLE, as long as needed — applies to description, examples and hints. Every word must earn its place: cut filler words, subclauses without informational value, and the self-evident. The length comes from the NUMBER of examples (variants), never from long texts.
|
|
||||||
|
|
||||||
Tone: clear German, direct, practical. Explain technical terms briefly on first use. No filler sentences, no introductory clichés.
|
|
||||||
|
|
||||||
Markdown in description and examples: normal paragraphs, `inline-code` for identifiers, **bold** sparingly for key points. No headings. Code examples ALWAYS as a code block with language tag (```sprache), never as inline code; prose examples (sentences, dialogues, scenarios) as plain text, NEVER forced into a code block. Identifiers, tags and commands (e.g. `<p>`, `git add`) in running text ALWAYS in backticks — never bare.
|
|
||||||
|
|
||||||
Write the element (title, description, examples) in GERMAN. Output ONLY valid JSON, no code fence, no other text:
|
|
||||||
{{"title": "...", "description": "...", "examples": ["```sprache\n...\n``` OR **Variante:** prose example"], "hints": []}}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
You revise EXACTLY ONE change proposal for a learning element on the topic "{topic}", following a user instruction.
|
|
||||||
|
|
||||||
CURRENT ELEMENT (JSON):
|
|
||||||
{element_json}
|
|
||||||
|
|
||||||
CURRENT PROPOSAL (JSON):
|
|
||||||
{suggestion_json}
|
|
||||||
|
|
||||||
USER INSTRUCTION:
|
|
||||||
{instruction}
|
|
||||||
|
|
||||||
Adjust the proposal per the instruction. Keep action/target/index, unless the instruction requires otherwise.
|
|
||||||
|
|
||||||
Style rules for content: AS LONG AS NEEDED and AS SHORT AS POSSIBLE. `inline-code` for identifiers, tags and commands — ALWAYS in backticks. examples in a topic-appropriate format (a code block with language tag ONLY for code, otherwise example sentences/mini-scenario) with a variant label (a code comment or a **bold** label). hints in telegraphic style: just the core statement.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- text: short, what is changed (max. 12 words, plain text)
|
|
||||||
- action: "remove" | "adjust" | "add"
|
|
||||||
- target: "title" | "description" | "examples" | "hints"
|
|
||||||
- index: 0-based position in the examples/hints array (otherwise null)
|
|
||||||
- content: the new complete content (empty for "remove")
|
|
||||||
|
|
||||||
Write the user-facing fields (`text`, `content`) in GERMAN. Output ONLY valid JSON, no code fence, no other text:
|
|
||||||
{{"change": {{"text": "...", "action": "adjust", "target": "hints", "index": 0, "content": "..."}}}}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
You check a learning element of a personal summary on the topic "{topic}" against the style rules and propose changes. The element is NOT changed directly — the user confirms each change individually.
|
|
||||||
|
|
||||||
CURRENT ELEMENT (JSON):
|
|
||||||
{element_json}
|
|
||||||
|
|
||||||
STYLE RULES:
|
|
||||||
1. title — concise, max. 8 words, plain text, no Markdown/backticks
|
|
||||||
2. description — what it is and what for: AT MOST 1–2 sentences
|
|
||||||
3. examples — SHORT and SIMPLE: the minimal example in a topic-appropriate format (see EXAMPLE FORMAT), no real-world complexity. One example per relevant variant, ordered from the usual to the special. A code block around a prose example is a style violation — as is a code example without a code block.
|
|
||||||
|
|
||||||
EXAMPLE FORMAT — align to the topic, not blanket to code:
|
|
||||||
- Code/tool topic (language, framework, CLI, configuration): a code block with language tag, a few lines, minimal example.
|
|
||||||
- Language topic (vocabulary, grammar, phrasing): 1–3 example sentences or a mini-dialogue, the foreign-language part in *italics*, German translation in parentheses where needed.
|
|
||||||
- Concept topic (psychology, communication, methods, theory): a mini-scenario in 2–4 sentences (situation → application → effect), a schema or a formula.
|
|
||||||
Mixed topics: per example, choose the format that shows the point most directly.
|
|
||||||
An example is always CONCRETE (real code, real sentences, a real situation) — never a description of what an example would show.
|
|
||||||
Each example names its variant: in code as a comment in the code syntax (e.g. `<!-- Einzelner Absatz -->`, `// Mit Default-Wert`), in prose as a leading **bold** label (e.g. **Höfliche Bitte:**).
|
|
||||||
4. hints — every hint must be IMPORTANT or USEFUL: a pitfall, a mnemonic or a best practice with real practical value. Remove the self-evident, niche knowledge and anything redundant with the element. Telegraphic style: just the core statement, cut filler verbs and derivations.
|
|
||||||
Before: "Browser fügen standardmäßig vertikalen Abstand vor und nach `<p>` ein — anpassbar mit `margin`."
|
|
||||||
After: "Browser-Abstand um `<p>` per `margin` anpassbar."
|
|
||||||
5. Length: AS LONG AS NEEDED and AS SHORT AS POSSIBLE. Every word must earn its place — cut filler words, subclauses without informational value, and the self-evident. But: never shorten at the cost of comprehensibility or correctness.
|
|
||||||
6. Markdown: `inline-code` for identifiers, tags and commands in running text (e.g. `<p>`, `git add`) — ALWAYS in backticks, never bare. Foreign-language example sentences in *italics*. **bold** sparingly. No headings.
|
|
||||||
7. Tone: clear German, direct, practical. No filler sentences.
|
|
||||||
|
|
||||||
For each style violation propose EXACTLY ONE change:
|
|
||||||
- text: short, what and why (max. 12 words, plain text)
|
|
||||||
- action: "remove" | "adjust" | "add"
|
|
||||||
- target: "title" | "description" | "examples" | "hints"
|
|
||||||
- index: 0-based position in the CURRENT examples or hints array (null for title/description; null for "add")
|
|
||||||
- content: the new/complete content (empty for "remove")
|
|
||||||
|
|
||||||
"remove" only for examples/hints. "add" sparingly — only when a style rule requires it (e.g. a missing variant comment belongs to "adjust", not "add"). If something already meets the rules: do NOT touch it.
|
|
||||||
|
|
||||||
Write the user-facing fields (`text`, `content`) in GERMAN. Output ONLY valid JSON, no code fence, no other text:
|
|
||||||
{{"changes": [{{"text": "...", "action": "adjust", "target": "hints", "index": 0, "content": "..."}}]}}
|
|
||||||
|
|
||||||
No style violation → {{"changes": []}}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
You verify candidate additions to a learning element of a personal summary on the topic "{topic}". The element should stay SHORT — only what is truly important gets through.
|
|
||||||
|
|
||||||
CURRENT ELEMENT (JSON):
|
|
||||||
{element_json}
|
|
||||||
|
|
||||||
CANDIDATES (JSON):
|
|
||||||
{candidates_json}
|
|
||||||
|
|
||||||
CONTEXT (excerpts from the topic material):
|
|
||||||
{context}
|
|
||||||
|
|
||||||
Check EACH candidate critically:
|
|
||||||
1. IMPORTANT? Does a learner need to know this? Reject nice-to-haves and niche knowledge.
|
|
||||||
2. REDUNDANT? Is the info already in the element or in another candidate? Reject, or merge duplicates.
|
|
||||||
3. CORRECT? Factually wrong or misleading → reject.
|
|
||||||
4. Does the target fit ("description" | "examples" | "hints")? Otherwise correct it.
|
|
||||||
|
|
||||||
Keep only candidates that pass all checks. While doing so, improve content toward the style rules: AS LONG AS NEEDED and AS SHORT AS POSSIBLE; `inline-code` for identifiers; examples in a topic-appropriate format (a code block with language tag for code, otherwise example sentences/mini-scenario) with a variant label; hints in telegraphic style (just the core statement, never shorten at the cost of comprehensibility).
|
|
||||||
|
|
||||||
Write the candidate fields (`text`, `content`) in GERMAN. Output ONLY valid JSON, no code fence, no other text:
|
|
||||||
{{"suggestions": [{{"text": "...", "target": "hints", "content": "..."}}]}}
|
|
||||||
|
|
||||||
No candidate passes → {{"suggestions": []}}
|
|
||||||
Reference in New Issue
Block a user