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.
339 lines
11 KiB
Python
339 lines
11 KiB
Python
#!/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()
|