Add Textual intake TUI, reach Beta (v0.1.0)

Implements the human-facing front door to the Ambrosiana RAG pipeline:
a Textual TUI that collects document-intake metadata (source location,
type, tier, licence, priority, optional title/notes) and writes a
structured markdown ticket for Claude Code to act on. The app never
touches the pipeline, any agent, or any model.

- Tickets write flat into a configured tickets_root (no immediate/
  queue subdirectories); priority is encoded in the filename instead
  (immediate_ prefix vs. no prefix for normal queue/order-of-arrival).
- tickets_root is read from ~/.config/ambrosiana-intake/config.toml,
  auto-created with a sensible default on first run rather than
  hardcoded.
- Save clears the form and fires a toast notification, fixing a bug
  where the confirmation message rendered below the visible viewport
  on any normal terminal size, giving no visible sign a save succeeded.
- Verified clean against flake8 and pylint per the CE OS script
  styleguide.
- Adds TODO.md tracking open items, including a cross-project link
  with the RIS design spec on multi-page document-set handling.

Created by John A. Hoeven with the ethical assistance of Claude AI.
This commit is contained in:
John A. Hoeven 2026-08-02 14:03:35 +02:00
commit bddbd552d2
Signed by: giovannino
GPG key ID: 306E507219506D4E
5 changed files with 454 additions and 2 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
handoff/
.venv/
__pycache__/

View file

@ -1,3 +1,83 @@
# ambrosiana-document-intake # Ambrosiana Document Intake
Document intake UI for the Ambrosiana RAG library **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*

29
TODO.md Normal file
View file

@ -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*

339
ambrosiana_intake.py Normal file
View file

@ -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()

1
requirements.txt Normal file
View file

@ -0,0 +1 @@
textual>=8.2.0