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:
parent
28dc3f34c9
commit
bddbd552d2
5 changed files with 454 additions and 2 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
handoff/
|
||||
.venv/
|
||||
__pycache__/
|
||||
84
README.md
84
README.md
|
|
@ -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
29
TODO.md
Normal 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
339
ambrosiana_intake.py
Normal 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
1
requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
textual>=8.2.0
|
||||
Loading…
Add table
Add a link
Reference in a new issue