update
This commit is contained in:
@@ -24,11 +24,11 @@ from pathlib import Path
|
||||
import database as db
|
||||
import embedding
|
||||
from agents import kill_process, cancel_scope, clear_scope
|
||||
from config import CONSENSUS_GRACE, CONSENSUS_MAX_ROUNDS, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS, QUELLE_RELEVANZ_CHUNK, QUELLE_RELEVANZ_SNIPPET, EMBEDDING_AKTIV, EMBEDDING_SUB_DUP, SUB_VARIANT_COS, SUBBLOCK_MAX, EVIDENCE_BUDGET_CHARS, EVIDENCE_CTX_LINES
|
||||
from config import CONSENSUS_GRACE, CONSENSUS_MAX_ROUNDS, DEFAULT_PROVIDER, EMBEDDING_AKTIV, EMBEDDING_SUB_DUP, SUB_VARIANT_COS, SUBBLOCK_MAX, EVIDENCE_BUDGET_CHARS, EVIDENCE_CTX_LINES
|
||||
from fsutil import atomic_write_json
|
||||
from jsonio import parse_json_text, read_json_file as _json_file
|
||||
from paths import arbeit_dir, blocks_path, question_pattern_path, project_dir, subblocks_path, source_path, source_crawl_dir, safe_folder
|
||||
from crawl import crawl
|
||||
from crawl import load_pages
|
||||
from pipeline import (
|
||||
GenContext, _extra, _gather_progress, _yesno_schema, _log, _prompt, _race,
|
||||
_semaphore, _timeout, run_single_slot,
|
||||
@@ -853,117 +853,10 @@ def _crawl_index(folder) -> dict[str, str]:
|
||||
return idx
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _triage_rules(folder, pages: list[str]) -> tuple[list[str], list[str]]:
|
||||
"""Deterministic content/noise filter (config.CRAWL_*). Substring match (lowercase) against
|
||||
URL + filename. Order: keep > noise > min_chars > keep. → (content, noise)."""
|
||||
folder = Path(folder)
|
||||
content, noise = [], []
|
||||
for fn in pages:
|
||||
lines = _read(folder / fn).splitlines()
|
||||
url = lines[0][len("QUELLE:"):].strip() if lines and lines[0].startswith("QUELLE:") else ""
|
||||
body = "\n".join(lines[1:]).strip()
|
||||
hay = f"{url}\n{fn}".lower()
|
||||
if any(p in hay for p in CRAWL_KEEP_PATTERNS):
|
||||
content.append(fn)
|
||||
elif any(p in hay for p in CRAWL_NOISE_PATTERNS):
|
||||
noise.append(fn)
|
||||
elif len(body) < CRAWL_MIN_CHARS:
|
||||
noise.append(fn)
|
||||
else:
|
||||
content.append(fn) # default: keep — everything with content stays
|
||||
return content, noise
|
||||
|
||||
|
||||
def _page_snippet(folder, fn: str) -> tuple[str, str]:
|
||||
"""(url, snippet) of a crawl page for the relevance gate. url from the QUELLE: line;
|
||||
snippet = body excerpt (navigation boilerplate is up front — the prompt ignores it).
|
||||
The URL is the primary signal (meaningful slug), the snippet only supports it."""
|
||||
lines = _read(Path(folder) / fn).splitlines()
|
||||
url = lines[0][len("QUELLE:"):].strip() if lines and lines[0].startswith("QUELLE:") else ""
|
||||
body = "\n".join(lines[1:]).strip()
|
||||
snippet = " ".join(body.split())[:QUELLE_RELEVANZ_SNIPPET]
|
||||
return (url or fn), snippet
|
||||
|
||||
|
||||
async def _relevance_triage(ctx: GenContext, set_p, files: dict, folder, content: list[str], spec: str, instructions: str) -> tuple[list[str], list[str]]:
|
||||
"""LLM topic gate after the rule filter: each content page ja/nein against the spec.
|
||||
Off-topic (different field) → out. Pattern like `_relevance_block`: small packages, 3 raters
|
||||
(`fast`), 2-of-3 consensus. CONSERVATIVE: drop only on a clear "nein" majority; dispute/gap/
|
||||
race error → keep. SAFETY: if the gate would drop ≥80 % (or all), everything stays
|
||||
(a spec mismatch/bug must not empty the source). → (kept, out) as filenames."""
|
||||
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
|
||||
work_dir = files["arbeit"]
|
||||
pages = sorted(content)
|
||||
if not pages:
|
||||
return content, []
|
||||
items = [_page_snippet(folder, fn) for fn in pages] # index aligns with `pages`
|
||||
chunks = _chunk_nums(list(range(len(pages))), _n_chunks(len(pages), QUELLE_RELEVANZ_CHUNK))
|
||||
n = len(chunks)
|
||||
|
||||
def rater_paths(c):
|
||||
return [work_dir / f"source-relevance-c{c}-{i}.json" for i in (1, 2, 3)]
|
||||
|
||||
def lset(idxs):
|
||||
return set(range(1, len(idxs) + 1))
|
||||
|
||||
async def _rate(c, idxs):
|
||||
local_set = lset(idxs)
|
||||
paths = rater_paths(c)
|
||||
existing = sum(1 for p in paths if _yesno_schema(_json_file(p), local_set))
|
||||
if existing >= 2:
|
||||
return True
|
||||
enum_lines = []
|
||||
for k, j in enumerate(idxs, 1):
|
||||
url, snip = items[j]
|
||||
enum_lines.append(f"{k}. {url}")
|
||||
if snip:
|
||||
enum_lines.append(f" {snip}")
|
||||
enum = "\n".join(enum_lines)
|
||||
pending = [(i, p) for i, p in enumerate(paths, 1) if not _yesno_schema(_json_file(p), local_set)]
|
||||
slots = [{
|
||||
"key": f"blocks-{topic}-source-relevance-c{c}-{i}",
|
||||
"prompt": _prompt("Source-Relevance", topic=topic, spec=spec, pages=enum, out_path=p, extra=_extra(instructions)),
|
||||
"role": "fast", "capabilities": "files",
|
||||
"payload": (lambda result, p=p, ids=local_set: _yesno_schema(_json_file(p), ids)),
|
||||
} for i, p in pending]
|
||||
new = await _race(topic, f"Relevance triage package {c}", slots, 2 - existing, _timeout("relevance", len(idxs)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
||||
return not is_cancelled() and new is not None
|
||||
|
||||
_qidx = _step_idx(topic, "Source prep") # gate runs in the source step (no own step)
|
||||
set_p(f"Check relevance against spec ({n} packages)…", step=_qidx)
|
||||
async def _report_triage(d, t):
|
||||
set_p(f"Check relevance against spec {d}/{t}…", step=_qidx)
|
||||
await _gather_progress([_rate(c, idxs) for c, idxs in enumerate(chunks, 1)], n, _report_triage)
|
||||
if is_cancelled():
|
||||
return content, [] # cancel → drop nothing (caller aborts)
|
||||
|
||||
# Vote per page: only a clear "nein" majority (≥2 and more than "ja") throws it out.
|
||||
dropped: list[str] = []
|
||||
for c, idxs in enumerate(chunks, 1):
|
||||
local_set = lset(idxs)
|
||||
rater = [d for p in rater_paths(c) if (d := _yesno_schema(_json_file(p), local_set))]
|
||||
for k in range(1, len(idxs) + 1):
|
||||
vote_list = [d[k] for d in rater if k in d]
|
||||
nein, ja = vote_list.count("nein"), vote_list.count("ja")
|
||||
if nein >= 2 and nein > ja:
|
||||
dropped.append(pages[idxs[k - 1]])
|
||||
|
||||
if dropped and len(dropped) >= max(1, int(len(pages) * 0.8)):
|
||||
_log(topic, f"Relevance triage: would drop {len(dropped)}/{len(pages)} — discarded (spec mismatch?), keeping all")
|
||||
return content, []
|
||||
dropped_set = set(dropped)
|
||||
keepers = [fn for fn in pages if fn not in dropped_set]
|
||||
return keepers, dropped
|
||||
|
||||
|
||||
async def _prepare_source(ctx: GenContext, set_p, files: dict, q: dict, folder, instructions: str) -> bool:
|
||||
"""Step "Source prep": crawl (link) + PDF convert + content/noise triage.
|
||||
Persists the triage in the coverage table (content). → True (ok) / False (cancel/error).
|
||||
thema: nothing. projekt/uni: only PDFs (curated folder, no triage)."""
|
||||
"""Step "Source prep": load link pages + PDF convert. → True (ok) / False (cancel/error).
|
||||
link: load each user-supplied URL (one page, no following); ALL pages count as content
|
||||
(curated — no triage). thema: nothing. projekt/uni: only PDFs (curated folder)."""
|
||||
topic, is_cancelled = ctx.topic, ctx.is_cancelled
|
||||
if not folder:
|
||||
return True # thema → no source to prepare
|
||||
@@ -973,28 +866,21 @@ async def _prepare_source(ctx: GenContext, set_p, files: dict, q: dict, folder,
|
||||
if await db.get_step_status(topic, "Source prep") == "done":
|
||||
return True
|
||||
if not _crawl_done(topic):
|
||||
set_p("Loading source (crawl)…", step=_step_idx(topic, "Source prep"))
|
||||
n = await asyncio.to_thread(crawl, q["location"], folder, cancelled=is_cancelled)
|
||||
urls = [ln.strip() for ln in q["location"].splitlines() if ln.strip()]
|
||||
set_p("Loading pages…", step=_step_idx(topic, "Source prep"))
|
||||
n = await asyncio.to_thread(load_pages, urls, folder, cancelled=is_cancelled)
|
||||
if is_cancelled():
|
||||
return False
|
||||
if not n:
|
||||
_blocks_errors[topic] = "Crawl yielded no content — check link/domain"
|
||||
_blocks_errors[topic] = "Links yielded no content — check the URLs"
|
||||
return False
|
||||
await asyncio.to_thread(_convert_pdfs, folder)
|
||||
pages = sorted(set(_crawl_index(folder).values()))
|
||||
if pages:
|
||||
set_p("Triaging pages…", step=_step_idx(topic, "Source prep"))
|
||||
# Curated links: the user picked them — keep every page, no rule/LLM triage.
|
||||
await db.delete_coverage(topic)
|
||||
content, noise = _triage_rules(folder, pages) # deterministic rule filter
|
||||
if q.get("spec") and content: # topic gate: separates the field (rules can't)
|
||||
content, dropped = await _relevance_triage(ctx, set_p, files, folder, content, q["spec"], instructions)
|
||||
if is_cancelled():
|
||||
return False
|
||||
if dropped:
|
||||
noise = sorted(set(noise) | set(dropped))
|
||||
_log(topic, f"LLM relevance: {len(dropped)} pages off-topic → noise")
|
||||
await db.mark_content(topic, sorted(content), sorted(noise))
|
||||
_log(topic, f"Triage: {len(content)} content / {len(noise)} noise of {len(pages)} (rules + LLM gate)")
|
||||
await db.mark_content(topic, pages, [])
|
||||
_log(topic, f"Links: {len(pages)} pages as content (curated, no triage)")
|
||||
await db.set_step_status(topic, "Source prep", "done")
|
||||
return True
|
||||
|
||||
@@ -1021,17 +907,41 @@ def _aspect_marker(title: str) -> int:
|
||||
|
||||
_REFERENCE_RE = re.compile(r'^(Satz|Lemma|Korollar|Bemerkung|Definition)\s*[\d.]+\s*(\([a-z]\)|[a-z])?\s*$', re.I)
|
||||
|
||||
# Catalogue scaffolding around a concept name: a leading label+number ("Satz 7.13", "Satz 7.6:")
|
||||
# or a trailing "(Definition 6.19)". _reference_strip peels it off and returns the concept remainder;
|
||||
# _is_reference is then just "nothing survives the strip". Generic — no author/domain whitelist. The
|
||||
# remainder feeds the rename step (a card must be titled by its concept, not its catalogue number).
|
||||
_REF_WORDS = (r'(?:Satz|Lemma|Korollar|Corollary|Bemerkung|Beobachtung|Definition|Def'
|
||||
r'|Theorem|Proposition|Folgerung|Kapitel|Abschnitt)')
|
||||
_REF_PREFIX_RE = re.compile(rf'^\s*{_REF_WORDS}\s*\d+(?:\.\d+)*[a-z]?\s*[:.\-–—]?\s*', re.I)
|
||||
_REF_SUFFIX_RE = re.compile(rf'\s*[\(\[]\s*{_REF_WORDS}\s*\d+(?:\.\d+)*[a-z]?\s*[\)\]]\s*$', re.I)
|
||||
_WHOLE_PAREN_RE = re.compile(r'^\s*[\(\[]\s*(.{2,}?)\s*[\)\]]\s*$')
|
||||
|
||||
|
||||
def _reference_strip(title: str) -> str:
|
||||
"""Concept remainder of a catalogue-referenced title, scaffolding removed. '' for a pure reference
|
||||
without a concept ('Bemerkung 7.22' → ''); the concept otherwise ('Satz 7.13 (Christofides)' →
|
||||
'Christofides', 'Satz 7.6: Kriterium für X' → 'Kriterium für X', 'N P … (Definition 6.19)' →
|
||||
'N P …'). A title without any catalogue reference comes back unchanged."""
|
||||
t = (title or "").strip()
|
||||
t = _REF_SUFFIX_RE.sub("", t).strip()
|
||||
m = _REF_PREFIX_RE.match(t)
|
||||
if m:
|
||||
t = t[m.end():].strip()
|
||||
if (w := _WHOLE_PAREN_RE.match(t)): # leading label left a lone '(Christofides)'
|
||||
t = w.group(1).strip()
|
||||
return t
|
||||
|
||||
|
||||
def _is_reference(title: str) -> bool:
|
||||
"""True for pure reference/placeholder titles WITHOUT meaningful content: "Satz 7.18", "Lemma 6.2",
|
||||
"Korollar 6.18" (number without a name) as well as marked spots "Bedingung (**)". NOT "Satz 6.24:
|
||||
Cook/Levin" (has a name) and NOT short technical symbols like "P⊆NP"/"Σ*" (real concepts)."""
|
||||
"""True for a pure reference/placeholder title WITHOUT concept content: "Satz 7.18", "Lemma 6.2",
|
||||
"Korollar 6.18" (number, no name) and marked spots "Bedingung (**)". False once a concept survives
|
||||
the catalogue strip ("Satz 7.13 (Christofides)", "Satz 6.24: Cook/Levin") and for short technical
|
||||
symbols like "P⊆NP"/"Σ*" (real concepts, no reference scaffolding)."""
|
||||
t = title.strip()
|
||||
if _REFERENCE_RE.match(t):
|
||||
return True
|
||||
if re.search(r'\(\*+\)', t): # marked spot "(**)" / "(*)"
|
||||
return True
|
||||
return False
|
||||
return bool(_REFERENCE_RE.match(t)) or not _reference_strip(t)
|
||||
|
||||
|
||||
def _canonical(candidates: list[dict], idxs: list[int], seen_norm: set[str]) -> dict:
|
||||
@@ -1118,6 +1028,15 @@ _CANON_STOP = re.compile(
|
||||
r'von|of|für|for|und|and|zum|zur|im)\b', re.I)
|
||||
|
||||
|
||||
# German compounds are head-final: "Cliquenproblem" = stem + optional Fuge + head noun "problem".
|
||||
# The standalone wrapper "problem" is already in _CANON_STOP, but a GLUED head hides the stem from ER
|
||||
# blocking ("Cliquenproblem" would never share a key with "Clique"). Strip the glued "problem" head so
|
||||
# the stem surfaces — a generic suffix rule, not a term list. The Fuge is only a consonant n/s
|
||||
# ("Cliquen-", "Entscheidungs-"): matching a vowel "e" would eat the stem's own "e" ("clique" → "cliqu").
|
||||
# The ≥3-char stem lookbehind keeps a card literally titled "Problem"/"Probleme" (→ _CANON_STOP) intact.
|
||||
_CANON_GLUED_HEAD = re.compile(r'(?<=\w{3})[ns]?problem(?:en|e|s)?\b', re.I)
|
||||
|
||||
|
||||
# Catalogue references ("Definition 6.19", "Satz 7.8") are scaffolding INCLUDING their number —
|
||||
# stripped as a phrase, so the digits don't pollute the key. Variant digits ("3-SAT") have no
|
||||
# scaffolding word in front and survive.
|
||||
@@ -1152,6 +1071,7 @@ def _canonical_key(title: str) -> str:
|
||||
s = _CANON_CATALOGUE.sub(' ', s)
|
||||
s = re.sub(r'≟|\bversus\b|\bvs\.?\b|=', ' opeq ', s) # equality / "vs" → one token
|
||||
s = re.sub(r'≤|⪯|→|⇒|⟹|\breduces?\s+to\b|\breduziert\b', ' opred ', s) # reduction → one token
|
||||
s = _CANON_GLUED_HEAD.sub(' ', s) # glued "…problem" head → stem
|
||||
s = _CANON_STOP.sub(' ', s)
|
||||
s = re.sub(r'[^\w ]', ' ', s) # drop punctuation/symbols
|
||||
return " ".join(sorted(t for t in s.split() if t))
|
||||
@@ -1340,16 +1260,21 @@ _REDUCTION_ONLY_OP = re.compile(r'[≤⪯]|→|⇒|⟹|->') # genuine reduction
|
||||
|
||||
def _is_reduction_statement(title: str) -> bool:
|
||||
"""True if the title is a reduction between two NAMED problems (both sides carry ≥3 letters and
|
||||
neither is a pure bound like "O(m)"). Rejects unary "X ∈ NP" and proof-size "|A| = O(m)"."""
|
||||
neither is a pure bound like "O(m)"). Rejects unary "X ∈ NP", proof-size "|A| = O(m)", and a
|
||||
construction/assignment suffix after the target ("3-SAT ≤ K-COLOR: G=(V,E) Konstruktion" is a
|
||||
construction fragment, not a standalone statement — a bare target carries no ':' or '=')."""
|
||||
t = _REL_STRIP.sub('', title, count=1)
|
||||
m = _REDUCTION_ONLY_OP.search(t)
|
||||
if not m:
|
||||
return False
|
||||
right = t[m.end():]
|
||||
if ':' in right or '=' in right: # construction/assignment detail after the target → fragment
|
||||
return False
|
||||
|
||||
def _named(s: str) -> bool:
|
||||
return len(re.findall(r'[a-zäöüß]', s, re.I)) >= 3 and not re.match(r'\s*[Oo]\s*\(', s)
|
||||
|
||||
return _named(t[:m.start()]) and _named(t[m.end():])
|
||||
return _named(t[:m.start()]) and _named(right)
|
||||
|
||||
|
||||
def _is_named_statement(title: str, desc: str = "") -> bool:
|
||||
|
||||
Reference in New Issue
Block a user