diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72d5673 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +handoff/ +.venv/ +__pycache__/ diff --git a/README.md b/README.md index 8834444..a5f4911 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,83 @@ -# ambrosiana-document-intake +# Ambrosiana Document Intake -Document intake UI for the Ambrosiana RAG library \ No newline at end of file +**Status:** Beta (v0.1.0) + +Document intake UI for the Ambrosiana RAG library. + +A minimal Textual TUI that collects the metadata needed to start a +document's journey into an Ambrosiana instance's RAG corpus. It does +not touch the pipeline, any agent, or any model — its only job is to +turn what a human knows about an incoming document into a structured +ticket that Claude Code (or a human) then acts on. + +Deployed per-device: each Ambrosiana instance gets its own copy, and an +intake ticket always targets that device's own RAG — there is no +cross-device routing field. + +## Requirements + +- Python 3.11+ (uses stdlib `tomllib`) +- `textual` (see `requirements.txt`) + +```bash +pip install -r requirements.txt +``` + +## Running + +```bash +python3 ambrosiana_intake.py +``` + +## Configuration + +The ticket output directory is read from +`~/.config/ambrosiana-intake/config.toml` (`tickets_root` key). If the +config file doesn't exist yet, it's created automatically on first run +with a default of `~/documents/rag-administration/ticket-queue`. Edit +the file directly to point at a different location. + +## Fields + +| Field | Required | Type | Notes | +|---|---|---|---| +| Source location | Yes | filepath or URL | | +| Source type | Yes | `upstream-doc` \| `ce-authored` \| `ce-experience` | | +| Source tier | Yes | `primary` \| `secondary` \| `forum/unmoderated` | | +| Licence | Yes | text | | +| Priority | Yes | `immediate need` \| `add to queue` | | +| Title / description | No | text | used for the ticket filename slug if given | +| Notes / context | No | text | free-form, passed through to the ticket body | + +## Output + +Each submission writes a markdown file with YAML front matter directly +into the configured `tickets_root` — there are no `immediate/`/`queue/` +subdirectories; priority is encoded in the filename instead: + +- Priority "immediate need" → `immediate_YYYYMMDD-HHMMSS_slug.md` +- Priority "add to queue" → `YYYYMMDD-HHMMSS_slug.md` (no tag — the + absence of a tag means normal, order-of-arrival handling) + +`slug` comes from the title if provided, otherwise the source +location's basename. + +On a successful save the form clears itself and a toast notification +confirms the ticket path — no need to scroll to see confirmation. + +The ticket is the handoff artefact: a human (or Claude Code, on +request) picks it up from `tickets_root` and runs the actual +scrape → vet → transform → commit → index pipeline against it. + +## Keybindings + +| Key | Action | +|---|---| +| `Ctrl+S` | Save ticket | +| `Ctrl+N` | Clear form | +| `Ctrl+Q` | Quit | + +--- + +*Built standing on the shoulders of billions of dwarves* +*Created by John A. Hoeven with the ethical assistance of Claude AI* diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..bdf43f6 --- /dev/null +++ b/TODO.md @@ -0,0 +1,29 @@ +# TODO + +- [ ] Move installation and usage instructions out of README.md into a project wiki. +- [ ] Move config from `~/.config/ambrosiana-intake/` (XDG) to a + `~/.local/etc/` subdirectory once that convention is settled. + Only `CONFIG_DIR` in `ambrosiana_intake.py` needs to change. +- [ ] Decide the ticket → corpus-doc field mapping — specifically when/how + `doc-state` gets set once a ticket is actually processed into a + RIS-registered doc-set (a ticket has no `doc-state` at intake time + either way; this was flagged during the RIS spec resync with + Claude Desktop and never formally resolved). +- [ ] Clean up leftover test tickets in the old, now-abandoned + `~/ambrosiana/intake-tickets/` location (pre-dates the config-driven + `tickets_root`). +- [ ] Optional: add docstrings to close pylint's remaining missing-docstring + notes (not required by the CE OS styleguide, low priority). +- [ ] Design how Ambrosiana/RIS handles multi-page document sets (source + URL is an index or first page of a larger set, not a single doc) — + needs a decision at the spec level (how the pipeline should crawl/ + section a doc set) before the intake form adds a dedicated field. + Using the Notes field as a manual workaround in the meantime + (first surfaced with the PEP8 styleguide intake). **Parallel + development item** with the RIS project — matching entry logged in + `~/projects/rosetta-indexing-system/OPEN-ITEMS.md` under "Adjacent, + not-yet-formalized systems". Keep both in sync as this gets designed. + +--- + +*Created by John A. Hoeven with the ethical assistance of Claude AI* diff --git a/ambrosiana_intake.py b/ambrosiana_intake.py new file mode 100644 index 0000000..6ea1ffb --- /dev/null +++ b/ambrosiana_intake.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +# Created by John A. Hoeven with the ethical assistance of Claude AI +# --------------------------------------------------------------------------- +# ambrosiana_intake.py +# ~/projects/ambrosiana-document-intake/ambrosiana_intake.py +# Version: v0.1.0 | Status: BETA +# --------------------------------------------------------------------------- +# Purpose: Textual TUI that collects document-intake metadata and writes +# a structured markdown ticket for Claude Code to act on. +# Target: Any Ambrosiana device (per-device deployment, no cross-device +# routing) +# Entry: python3 ambrosiana_intake.py +# Depends: textual (see requirements.txt) +# Config: ~/.config/ambrosiana-intake/config.toml (tickets_root), created +# with a default on first run if not present +# --------------------------------------------------------------------------- +""" +Ambrosiana Intake — CE OS document intake ticket generator + +Collects the finalized intake variable set for an incoming document and +writes a structured markdown ticket (YAML front matter) that is handed +to Claude Code as the instruction to run the actual scrape/vet/commit/ +index pipeline. This app never touches the pipeline, any agent, or any +model — it is a pure human-input-to-structured-document step. + +Built standing on the shoulders of billions of dwarves. +Created by John A. Hoeven with the ethical assistance of Claude AI. +License: The Unlicense — https://unlicense.org +""" + +from __future__ import annotations + +import re +import socket +import tomllib +from datetime import datetime +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.containers import Horizontal, VerticalScroll +from textual.widgets import ( + Button, + Footer, + Header, + Input, + Label, + Select, + Static, + TextArea, +) + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +# XDG for now by explicit choice — planned move to a ~/.local/etc +# subdirectory later. CONFIG_DIR is the only line that needs to change +# when that happens. +CONFIG_DIR = Path.home() / ".config" / "ambrosiana-intake" +CONFIG_FILE = CONFIG_DIR / "config.toml" +DEFAULT_TICKETS_ROOT = Path.home() / "documents" / "rag-administration" / "ticket-queue" + + +def load_tickets_root() -> Path: + """Read tickets_root from config.toml, writing a default config on first run.""" + if not CONFIG_FILE.exists(): + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + CONFIG_FILE.write_text( + "# Ambrosiana Intake configuration\n" + f'tickets_root = "{DEFAULT_TICKETS_ROOT}"\n', + encoding="utf-8", + ) + return DEFAULT_TICKETS_ROOT + + with CONFIG_FILE.open("rb") as config_file: + config = tomllib.load(config_file) + raw_path = config.get("tickets_root", str(DEFAULT_TICKETS_ROOT)) + return Path(raw_path).expanduser() + + +TICKETS_ROOT = load_tickets_root() +TICKETS_ROOT.mkdir(parents=True, exist_ok=True) + +SOURCE_TYPE_OPTIONS = [ + ("upstream-doc", "upstream-doc"), + ("ce-authored", "ce-authored"), + ("ce-experience", "ce-experience"), +] + +SOURCE_TIER_OPTIONS = [ + ("primary", "primary"), + ("secondary", "secondary"), + ("forum/unmoderated", "forum-unmoderated"), +] + +PRIORITY_OPTIONS = [ + ("Immediate need", "immediate"), + ("Add to queue", "queue"), +] + +REQUIRED_FIELDS = ("source_location", "source_type", "source_tier", "licence", "priority") + + +def slugify(text: str, fallback: str) -> str: + text = text.strip().lower() + if not text: + text = fallback + text = re.sub(r"[^\w\s-]", "", text) + text = re.sub(r"[\s_-]+", "-", text).strip("-") + return text[:60] or fallback + + +# --------------------------------------------------------------------------- +# App +# --------------------------------------------------------------------------- + + +class AmbrosianaIntakeApp(App): + CSS = """ + Screen { + background: $surface; + align: center top; + } + + #form-scroll { + width: 90; + max-width: 100%; + padding: 1 2; + } + + #device-banner { + width: 100%; + content-align: center middle; + color: $text-muted; + padding-bottom: 1; + } + + .field-label { + padding-top: 1; + color: $text; + text-style: bold; + } + + .required-marker { + color: $error; + } + + .optional-marker { + color: $text-muted; + text-style: italic; + } + + Input, Select, TextArea { + width: 100%; + } + + #notes-area { + height: 5; + border: round $primary-darken-1; + } + + #button-row { + width: 100%; + height: auto; + padding-top: 2; + align-horizontal: center; + } + + #button-row Button { + margin: 0 1; + } + + #status-line { + width: 100%; + padding-top: 1; + text-align: center; + color: $success; + } + + #status-line.-error { + color: $error; + } + """ + + BINDINGS = [ + ("ctrl+s", "submit", "Save ticket"), + ("ctrl+n", "reset_form", "Clear form"), + ("ctrl+q", "quit", "Quit"), + ] + + def __init__(self) -> None: + super().__init__() + self.hostname = socket.gethostname() + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + with VerticalScroll(id="form-scroll"): + yield Static( + f"Ambrosiana Intake — running on [b]{self.hostname}[/b]", + id="device-banner", + ) + + yield Label("Source location [required]", classes="field-label") + yield Input(placeholder="filepath or URL", id="source_location") + + yield Label("Source type [required]", classes="field-label") + yield Select(SOURCE_TYPE_OPTIONS, id="source_type", prompt="Choose source type") + + yield Label("Source tier [required]", classes="field-label") + yield Select(SOURCE_TIER_OPTIONS, id="source_tier", prompt="Choose source tier") + + yield Label("Licence [required]", classes="field-label") + yield Input(placeholder="e.g. The Unlicense, CC-BY-4.0, proprietary...", id="licence") + + yield Label("Priority [required]", classes="field-label") + yield Select(PRIORITY_OPTIONS, id="priority", prompt="Choose priority") + + yield Label("Title / description (optional)", classes="field-label optional-marker") + yield Input(placeholder="short human-readable title", id="title") + + yield Label("Notes / context (optional)", classes="field-label optional-marker") + yield TextArea(id="notes-area") + + with Horizontal(id="button-row"): + yield Button("Save ticket", id="submit", variant="success") + yield Button("Clear", id="reset", variant="warning") + yield Button("Quit", id="quit", variant="error") + + yield Static("", id="status-line") + yield Footer() + + # -- helpers ------------------------------------------------------ + + def _collect(self) -> dict[str, str]: + return { + "source_location": self.query_one("#source_location", Input).value.strip(), + "source_type": self.query_one("#source_type", Select).value, + "source_tier": self.query_one("#source_tier", Select).value, + "licence": self.query_one("#licence", Input).value.strip(), + "priority": self.query_one("#priority", Select).value, + "title": self.query_one("#title", Input).value.strip(), + "notes": self.query_one("#notes-area", TextArea).text.strip(), + } + + def _set_status(self, message: str, error: bool = False) -> None: + status = self.query_one("#status-line", Static) + status.update(message) + status.set_class(error, "-error") + if message: + self.notify(message, severity="error" if error else "information") + + def _clear_form(self) -> None: + self.query_one("#source_location", Input).value = "" + self.query_one("#source_type", Select).clear() + self.query_one("#source_tier", Select).clear() + self.query_one("#licence", Input).value = "" + self.query_one("#priority", Select).clear() + self.query_one("#title", Input).value = "" + self.query_one("#notes-area", TextArea).text = "" + self.query_one("#source_location", Input).focus() + + def _write_ticket(self, data: dict[str, str]) -> Path | None: + missing = [f for f in REQUIRED_FIELDS if not data.get(f) or data.get(f) is Select.BLANK] + if missing: + self._set_status(f"Missing required field(s): {', '.join(missing)}", error=True) + return None + + now = datetime.now() + + fallback_slug = Path(data["source_location"]).stem or "untitled" + slug = slugify(data["title"] or fallback_slug, fallback_slug) + priority_tag = "immediate_" if data["priority"] == "immediate" else "" + filename = f"{priority_tag}{now.strftime('%Y%m%d-%H%M%S')}_{slug}.md" + ticket_path = TICKETS_ROOT / filename + + front_matter_lines = [ + "---", + "ticket_type: ambrosiana-intake", + f"created: {now.isoformat(timespec='seconds')}", + f"device: {self.hostname}", + f"source_location: \"{data['source_location']}\"", + f"source_type: {data['source_type']}", + f"source_tier: {data['source_tier']}", + f"licence: \"{data['licence']}\"", + f"priority: {data['priority']}", + f"title: \"{data['title']}\"" if data["title"] else "title: \"\"", + "---", + "", + ] + + body_lines = [] + if data["title"]: + body_lines.append(f"# {data['title']}") + body_lines.append("") + body_lines.append(f"**Source:** {data['source_location']}") + body_lines.append(f"**Source-type:** `{data['source_type']}`") + body_lines.append(f"**Source-tier:** `{data['source_tier']}`") + body_lines.append(f"**Licence:** {data['licence']}") + body_lines.append(f"**Priority:** {data['priority']}") + body_lines.append("") + if data["notes"]: + body_lines.append("## Notes / context") + body_lines.append("") + body_lines.append(data["notes"]) + body_lines.append("") + body_lines.append("---") + body_lines.append("*Ambrosiana intake ticket — awaiting pipeline run via Claude Code*") + + ticket_path.write_text("\n".join(front_matter_lines + body_lines), encoding="utf-8") + return ticket_path + + # -- actions -------------------------------------------------------- + + def action_submit(self) -> None: + self._do_submit() + + def action_reset_form(self) -> None: + self._clear_form() + self._set_status("") + + def _do_submit(self) -> None: + data = self._collect() + path = self._write_ticket(data) + if path is not None: + self._set_status(f"Saved: {path}") + self._clear_form() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "submit": + self._do_submit() + elif event.button.id == "reset": + self._clear_form() + self._set_status("") + elif event.button.id == "quit": + self.exit() + + +if __name__ == "__main__": + AmbrosianaIntakeApp().run() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ac6218b --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +textual>=8.2.0