Self-hosted knowledge management for SMEs: a split-screen Markdown editor whose sections an LLM refines while you write, and RAG question answering over the documents that result. FastAPI + Postgres/pgvector on the back, SvelteKit on the front, everything OpenAI-compatible and self-hostable. Squashed into a single commit; the development history stays local. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
132 lines
4.6 KiB
Python
132 lines
4.6 KiB
Python
"""Find the section of a Markdown document the cursor sits in.
|
|
|
|
The refinement endpoint refines exactly one section at a time (FIM-style),
|
|
so this is the AUTHORITATIVE boundary computation — the client mirrors it for
|
|
a visual highlight, but the server owns it. A section runs from the nearest
|
|
heading at or above the cursor down to the line before the next heading of
|
|
the same or higher level; content before the first heading is its own
|
|
section. A section whose body exceeds the chunk cap narrows to the blank-line
|
|
paragraph at the cursor, so a large document never refines as one giant block.
|
|
|
|
Shares the heading regex, fence-awareness and cap with `rag/chunking.py` so
|
|
"what is a section" means the same thing to refinement and to indexing.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from app.rag.chunking import HEADING_RE, TARGET_CHUNK_CHARS
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ActiveSection:
|
|
start_line: int # 1-based, inclusive, into content_md
|
|
end_line: int # 1-based, inclusive
|
|
|
|
|
|
def _heading_lines(lines: list[str]) -> list[tuple[int, int]]:
|
|
"""(line_index_0based, level) for every heading line, ignoring fences."""
|
|
headings: list[tuple[int, int]] = []
|
|
in_fence = False
|
|
for i, line in enumerate(lines):
|
|
if line.lstrip().startswith("```"):
|
|
in_fence = not in_fence
|
|
continue
|
|
if in_fence:
|
|
continue
|
|
match = HEADING_RE.match(line)
|
|
if match:
|
|
headings.append((i, len(match.group(1))))
|
|
return headings
|
|
|
|
|
|
def _paragraph_at(
|
|
lines: list[str], start0: int, end0: int, cursor0: int
|
|
) -> tuple[int, int] | None:
|
|
"""The blank-line-delimited block (fence-aware) at the cursor, within
|
|
[start0, end0]. Falls back to the block just before the cursor when it
|
|
sits on a blank gap, else the first block."""
|
|
blocks: list[tuple[int, int]] = []
|
|
block_start: int | None = None
|
|
in_fence = False
|
|
for i in range(start0, end0 + 1):
|
|
line = lines[i]
|
|
if line.lstrip().startswith("```"):
|
|
in_fence = not in_fence
|
|
if block_start is None:
|
|
block_start = i
|
|
continue
|
|
if not line.strip() and not in_fence:
|
|
if block_start is not None:
|
|
blocks.append((block_start, i - 1))
|
|
block_start = None
|
|
elif block_start is None:
|
|
block_start = i
|
|
if block_start is not None:
|
|
blocks.append((block_start, end0))
|
|
if not blocks:
|
|
return None
|
|
for b_start, b_end in blocks:
|
|
if b_start <= cursor0 <= b_end:
|
|
return b_start, b_end
|
|
for b_start, b_end in reversed(blocks):
|
|
if b_end < cursor0:
|
|
return b_start, b_end
|
|
return blocks[0]
|
|
|
|
|
|
def active_section(content_md: str, cursor_line: int) -> ActiveSection:
|
|
lines = content_md.splitlines()
|
|
n = len(lines)
|
|
if n == 0:
|
|
return ActiveSection(1, 1)
|
|
cursor0 = max(1, min(cursor_line, n)) - 1
|
|
|
|
headings = _heading_lines(lines)
|
|
owner: tuple[int, int] | None = None
|
|
for idx, level in headings:
|
|
if idx <= cursor0:
|
|
owner = (idx, level)
|
|
else:
|
|
break
|
|
|
|
if owner is None:
|
|
# Preamble before the first heading (or a document with no headings).
|
|
start0 = 0
|
|
end0 = headings[0][0] - 1 if headings else n - 1
|
|
else:
|
|
start0, owner_level = owner
|
|
end0 = n - 1
|
|
for idx, level in headings:
|
|
if idx > start0 and level <= owner_level:
|
|
end0 = idx - 1
|
|
break
|
|
|
|
# Trailing blank lines belong to the separation before the next section,
|
|
# not to this one: keeping them in the range would let an accepted
|
|
# suggestion swallow the blank line above the next heading.
|
|
while end0 > start0 and not lines[end0].strip():
|
|
end0 -= 1
|
|
|
|
body = "\n".join(lines[start0 : end0 + 1])
|
|
if len(body) > TARGET_CHUNK_CHARS:
|
|
narrowed = _paragraph_at(lines, start0, end0, cursor0)
|
|
if narrowed is not None:
|
|
start0, end0 = narrowed
|
|
|
|
return ActiveSection(start_line=start0 + 1, end_line=end0 + 1)
|
|
|
|
|
|
def slice_lines(
|
|
content_md: str, start_line: int, end_line: int
|
|
) -> tuple[str, str, str]:
|
|
"""(prefix, section, suffix) split at the 1-based inclusive line range.
|
|
|
|
The section is the lines the model refines; prefix/suffix are the rest of
|
|
the document, handed to the model as context it must not re-emit.
|
|
"""
|
|
lines = content_md.splitlines()
|
|
prefix = "\n".join(lines[: start_line - 1])
|
|
section = "\n".join(lines[start_line - 1 : end_line])
|
|
suffix = "\n".join(lines[end_line:])
|
|
return prefix, section, suffix
|