diff --git a/README.md b/README.md index dff92b8..14bfe5f 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,19 @@ uv run uvicorn app.main:app --reload > π **After this, day-to-day is just: edit markdown β re-run the import.** > See [Updating the documents](#updating-the-documents) below. +## Using the UI + +- **Chat** (`/`) β ask questions; answers stream in with **source chips** + that cite the exact documents used. Clicking a chip opens that document + **in a new tab**. +- **Document viewer** (`/document.html?source=β¦&path=β¦`) β the full text of + any indexed document, served from the database (no filesystem access): + markdown is rendered, every other format (`yaml`, `json`, `py`, `txt`, β¦) + is shown as escaped monospace text. Unknown documents get a designed + not-found state with a link back to the index. +- **Sources** (`/sources.html`) β the indexed document list; the *Path* + column links each document to the viewer in a new tab. + ## Updating the documents **This is the workflow you'll use most.** The knowledge base is refreshed by diff --git a/app/api/docs.py b/app/api/docs.py index db122c7..74ba818 100644 --- a/app/api/docs.py +++ b/app/api/docs.py @@ -1,17 +1,32 @@ -"""GET /api/docs β the indexed document list (feeds the Sources page).""" +"""GET /api/docs β the indexed document list (feeds the Sources page). + +GET /api/documents/content β one indexed document's full content (feeds the +clickable document viewer, phase 10). DB-only by design: the (source, path) +pair is looked up as a row, so there is no filesystem access and no +path-traversal surface β ``../``-style values simply aren't rows (β 404). +""" from __future__ import annotations -from fastapi import APIRouter, Depends +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import func, select from sqlalchemy.orm import Session from app.db import get_db from app.models import Chunk, Document -from app.schemas import DocList, DocSummary +from app.schemas import DocContent, DocList, DocSummary router = APIRouter(tags=["kb"]) +def doc_format(path: str) -> str: + """Lowercased path suffix without its dot (``kubernetes.md`` β ``md``, + ``notes/deep.Markdown`` β ``markdown``); ``text`` when the path has no + suffix β the value shown in the viewer's format badge.""" + return Path(path).suffix.lower().lstrip(".") or "text" + + @router.get("/docs", response_model=DocList) def list_documents(db: Session = Depends(get_db)) -> DocList: # noqa: B008 """All indexed documents with per-document chunk counts. @@ -45,3 +60,33 @@ def list_documents(db: Session = Depends(get_db)) -> DocList: # noqa: B008 for row in rows ] ) + + +@router.get("/documents/content", response_model=DocContent) +def get_document_content( + source: str, path: str, db: Session = Depends(get_db) # noqa: B008 +) -> DocContent: + """Full content of one indexed document, looked up by ``(source, path)``. + + Stateless (A10) and database-only: unknown pairs β including traversal + strings such as ``../../etc/passwd`` β are just non-existent rows and + map to 404 ``{detail: "document not found"}``. + """ + row = db.execute( + select(Document, func.count(Chunk.id).label("chunks")) + .outerjoin(Chunk, Chunk.document_id == Document.id) + .where(Document.source == source, Document.path == path) + .group_by(Document.id) + ).first() + if row is None: + raise HTTPException(status_code=404, detail="document not found") + doc, chunks = row + return DocContent( + source=doc.source, + path=doc.path, + title=doc.title, + format=doc_format(doc.path), + content=doc.content, + indexed_at=doc.indexed_at.isoformat(), + chunks=chunks, + ) diff --git a/app/schemas.py b/app/schemas.py index 7cd3da4..888b2f3 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -61,3 +61,15 @@ class DocList(BaseModel): """Response of ``GET /api/docs`` (empty list β designed empty state).""" documents: list[DocSummary] + + +class DocContent(BaseModel): + """One indexed document's full content (feeds the viewer page, phase 10).""" + + source: str + path: str + title: str + format: str + content: str + indexed_at: str + chunks: int diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 1013c09..78a765b 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -65,46 +65,14 @@ const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches; const SCROLL = reducedMotion ? "auto" : "smooth"; -/* ---------- tiny, safe markdown renderer (no external libs, no CDN) ---------- */ -export function escapeHtml(s) { - return s.replace(/[&<>"']/g, (c) => ({ - "&": "&", "<": "<", ">": ">", '"': """, "'": "'", - }[c])); -} - -export function renderMarkdown(md) { - // 1. Protect fenced code blocks. - const codeBlocks = []; - let text = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => { - codeBlocks.push(`
${escapeHtml(code.replace(/\n$/, ""))}`);
- return `\u0000CODE${codeBlocks.length - 1}\u0000`;
- });
-
- // 2. Escape everything else, then apply inline + block transforms.
- text = escapeHtml(text)
- .replace(/`([^`\n]+)`/g, "$1")
- .replace(/\*\*([^*]+)\*\*/g, "$1")
- .replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1$2")
- .replace(/^### (.*)$/gm, "${b.replace(/\n/g, "
")}
(mono, horizontal scroll).
+ *
+ * XSS-safe by construction: markdown is escaped before transform, raw
+ * formats are set via textContent, and every document-derived string
+ * (title, badges, path) is written with textContent β never innerHTML.
+ *
+ * A missing document (unknown pair, missing params, network error) shows
+ * the designed not-found card with a link back to the Sources page.
+ */
+
+const params = new URLSearchParams(window.location.search);
+const source = params.get("source") || "";
+const path = params.get("path") || "";
+
+const titleEl = document.querySelector("#doc-title");
+const metaEl = document.querySelector("#doc-meta");
+const contentEl = document.querySelector("#doc-content");
+const notFoundEl = document.querySelector("#doc-not-found");
+const mainEl = document.querySelector("#main");
+const backLink = document.querySelector("#doc-back");
+
+/* Back: prefer the browser's own history when there is one (the viewer was
+ * opened from this tab's session); a fresh tab lands on the Sources page. */
+backLink.addEventListener("click", (e) => {
+ if (window.history.length > 1) {
+ e.preventDefault();
+ window.history.back();
+ }
+});
+
+function fmtDate(iso) {
+ try {
+ return new Date(iso).toLocaleString();
+ } catch {
+ return iso;
+ }
+}
+
+function contentUrl(s, p) {
+ return "/api/documents/content?source=" + encodeURIComponent(s) + "&path=" + encodeURIComponent(p);
+}
+
+function metaBadge(cls, text) {
+ const el = document.createElement("span");
+ el.className = cls;
+ el.textContent = text;
+ return el;
+}
+
+function render(doc) {
+ titleEl.textContent = doc.title;
+ document.title = `${doc.title} Β· Brain of Reese`;
+
+ const pathCode = document.createElement("code");
+ pathCode.className = "doc-path";
+ pathCode.textContent = doc.path;
+ metaEl.replaceChildren(
+ metaBadge("doc-source-badge", doc.source),
+ metaBadge("format-badge", doc.format),
+ pathCode,
+ metaBadge("doc-indexed", `Indexed ${fmtDate(doc.indexed_at)}`),
+ metaBadge("doc-chunks", `${doc.chunks} chunk${doc.chunks === 1 ? "" : "s"}`),
+ );
+
+ notFoundEl.hidden = true;
+ contentEl.replaceChildren();
+ if (doc.format === "md" || doc.format === "markdown") {
+ const wrap = document.createElement("div");
+ wrap.className = "doc-md";
+ wrap.innerHTML = renderMarkdown(doc.content); // escape-first: XSS-safe
+ contentEl.appendChild(wrap);
+ } else {
+ const pre = document.createElement("pre");
+ pre.className = "doc-raw";
+ pre.textContent = doc.content; // text node: never parsed as HTML
+ contentEl.appendChild(pre);
+ }
+}
+
+function showNotFound() {
+ titleEl.textContent = "Document not found";
+ document.title = "Document not found Β· Brain of Reese";
+ metaEl.replaceChildren();
+ contentEl.replaceChildren();
+ notFoundEl.hidden = false;
+}
+
+async function load() {
+ try {
+ if (!source || !path) {
+ showNotFound();
+ return;
+ }
+ const r = await fetch(contentUrl(source, path));
+ if (!r.ok) {
+ showNotFound();
+ return;
+ }
+ render(await r.json());
+ } catch {
+ showNotFound();
+ } finally {
+ mainEl.focus(); // move focus to main on load (a11y)
+ }
+}
+
+load();
diff --git a/frontend/assets/markdown.js b/frontend/assets/markdown.js
new file mode 100644
index 0000000..adfe7e3
--- /dev/null
+++ b/frontend/assets/markdown.js
@@ -0,0 +1,51 @@
+/* Brain of Reese β shared markdown renderer (no external libs, no CDN).
+ *
+ * Extracted from app.js (phase 10) so the chat page and the document
+ * viewer share the exact same escape-first renderer: every character is
+ * HTML-escaped before any markup transform runs, so document (or user)
+ * content can never inject live HTML/XSS. Classic script on purpose:
+ * index.html and document.html load it via a plain relative
+
+