{"id":50903773,"url":"https://github.com/hatimhtm/strata-enquiry-triage","last_synced_at":"2026-06-16T05:05:28.300Z","repository":{"id":357591103,"uuid":"1237637959","full_name":"hatimhtm/strata-enquiry-triage","owner":"hatimhtm","description":"Claude-powered enquiry triage CLI. Classifies a client enquiry into a closed category enum, self-rates confidence + urgency, drafts a polite AU-English reply, and recommends a routing action. Draft-not-send by design. Single-file Python + Anthropic SDK.","archived":false,"fork":false,"pushed_at":"2026-05-13T11:38:00.000Z","size":41,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-13T13:28:08.538Z","etag":null,"topics":["ai","ai-developer","anthropic","anthropic-api","classification","claude","claude-sonnet","cli","client-work","enquiry-triage","freelance","llm","mit","prompt-engineering","python","strata-management","structured-output","workflow-automation"],"latest_commit_sha":null,"homepage":"https://hatimhtm.github.io/strata-enquiry-triage/","language":"CSS","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/hatimhtm.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"buy_me_a_coffee":"hatimelhassak"}},"created_at":"2026-05-13T11:15:46.000Z","updated_at":"2026-05-13T11:45:40.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/hatimhtm/strata-enquiry-triage","commit_stats":null,"previous_names":["hatimhtm/strata-enquiry-triage"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/hatimhtm/strata-enquiry-triage","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hatimhtm%2Fstrata-enquiry-triage","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hatimhtm%2Fstrata-enquiry-triage/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hatimhtm%2Fstrata-enquiry-triage/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hatimhtm%2Fstrata-enquiry-triage/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/hatimhtm","download_url":"https://codeload.github.com/hatimhtm/strata-enquiry-triage/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hatimhtm%2Fstrata-enquiry-triage/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34391761,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-16T02:00:06.860Z","response_time":126,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ai","ai-developer","anthropic","anthropic-api","classification","claude","claude-sonnet","cli","client-work","enquiry-triage","freelance","llm","mit","prompt-engineering","python","strata-management","structured-output","workflow-automation"],"created_at":"2026-06-16T05:05:27.437Z","updated_at":"2026-06-16T05:05:28.293Z","avatar_url":"https://github.com/hatimhtm.png","language":"CSS","funding_links":["https://buymeacoffee.com/hatimelhassak"],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\n  \u003cpicture\u003e\n    \u003csource media=\"(prefers-color-scheme: dark)\" srcset=\"assets-readme/hero-banner-dark.svg\" /\u003e\n    \u003cimg src=\"assets-readme/hero-banner.svg\" alt=\"Strata Enquiry Triage\" width=\"100%\" /\u003e\n  \u003c/picture\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://hatimhtm.github.io/strata-enquiry-triage/\"\u003e\u003cimg src=\"https://img.shields.io/badge/▶_LIVE_DEMO-CCFF00?style=for-the-badge\u0026labelColor=1A1A1A\" alt=\"Live demo\" /\u003e\u003c/a\u003e\n  \u003ca href=\"https://github.com/hatimhtm/strata-enquiry-triage/actions/workflows/ci.yml\"\u003e\u003cimg src=\"https://img.shields.io/github/actions/workflow/status/hatimhtm/strata-enquiry-triage/ci.yml?branch=main\u0026style=for-the-badge\u0026label=CI\u0026labelColor=1A1A1A\u0026color=CCFF00\" alt=\"CI\" /\u003e\u003c/a\u003e\n  \u003cimg src=\"https://img.shields.io/badge/Python-3.10+-1A1A1A?style=for-the-badge\u0026logo=python\u0026logoColor=CCFF00\" alt=\"Python 3.10+\" /\u003e\n  \u003cimg src=\"https://img.shields.io/badge/Claude-Sonnet_4.5-1A1A1A?style=for-the-badge\u0026logo=anthropic\u0026logoColor=CCFF00\" alt=\"Claude Sonnet 4.5\" /\u003e\n  \u003cimg src=\"https://img.shields.io/badge/Tests-19_passing-1A1A1A?style=for-the-badge\u0026labelColor=1A1A1A\u0026color=CCFF00\" alt=\"19 tests passing\" /\u003e\n  \u003ca href=\"LICENSE\"\u003e\u003cimg src=\"https://img.shields.io/badge/LICENSE-MIT-1A1A1A?style=for-the-badge\u0026labelColor=1A1A1A\u0026color=CCFF00\" alt=\"MIT\" /\u003e\u003c/a\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003cem\u003eA small, focused CLI that turns one inbound client enquiry into a triaged, actionable record for a staff member. The \u003cstrong\u003eAnthropic API\u003c/strong\u003e (Claude Sonnet 4.5) reads the email, picks a category from a closed enum, rates its own confidence, scores urgency, drafts a polite reply in Australian English, and recommends a routing action. Designed for a strata-management workflow where \u003cstrong\u003esend-without-review is not on the table\u003c/strong\u003e — every output is a draft a human reads before it goes out. Built as the Part 1 deliverable for the Strata Management Consultants AI Developer assessment.\u003c/em\u003e\n\u003c/p\u003e\n\n---\n\n### `/// WHAT IT IS`\n\n```\n┌────────────────────────────────────────────────────────────────────┐\n│ INPUT                                                              │\n│ ▸ One client enquiry — from --text, --file, or stdin               │\n├────────────────────────────────────────────────────────────────────┤\n│ PIPELINE                                                           │\n│ ▸ Short-circuit: empty / whitespace → \"spam_or_unclear\"            │\n│ ▸ Anthropic messages.create with a strict JSON-output system       │\n│   prompt (closed category enum, no-invented-facts rule)            │\n│ ▸ Tolerant parser — handles a stray ```json fence if the model     │\n│   adds one despite instructions                                    │\n│ ▸ Typed dataclass — no string-typed downstream code                │\n├────────────────────────────────────────────────────────────────────┤\n│ OUTPUT                                                             │\n│ ▸ category    — new_client · support_request · complaint ·         │\n│                 billing_question · general_question · spam_unclear │\n│ ▸ confidence  — float 0.0–1.0  (self-rated, lowered on ambiguity)  │\n│ ▸ urgency     — low · normal · high  (high = legal / safety /      │\n│                 financial deadline / AGM date)                     │\n│ ▸ sender_intent       — one-line restatement of what they want     │\n│ ▸ suggested_reply     — polite AU-English draft, signed as the     │\n│                         firm, never invents facts                  │\n│ ▸ recommended_action  — short routing instruction for staff        │\n│ ▸ flags               — needs_human_review · contains_pii ·        │\n│                         ambiguous_input · out_of_scope             │\n└────────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n### `/// WHY IT EXISTS`\n\nStrata firms get a high volume of inbound emails across two or three shared inboxes — quote requests from prospective committees, ceiling-leak complaints from owners, AGM-minute requests, invoice questions, the lot. The office manager spends the first 90 minutes of every morning reading and sorting them.\n\nThis CLI does the reading and sorting. **It doesn't auto-send.** It produces a structured triage record + draft reply so a staff member can scan a queue, edit the draft, and click send — instead of writing every reply from scratch. The whole point is to free up human attention for the cases that actually need judgement.\n\nIt's a CLI deliberately. The brief asked for a small working tool, and a single-file CLI is the smallest thing that demonstrates the loop end-to-end. It also plugs into anything: an IMAP poller, a Laravel queue worker, an n8n flow, a WordPress webhook target, a Zapier \"Run Command\" node.\n\n---\n\n### `/// HIGHLIGHTS`\n\n| | |\n|---|---|\n| **Closed category enum** | Six categories defined in the system prompt. Free-text labels drift (`complaint` vs `Complaint` vs `service complaint`) and break downstream routing — the enum is enforced in prose and the model is told it's the only valid set. |\n| **Self-rated confidence** | The model is explicitly told to lower its confidence on ambiguous inputs. Without that rule, models will pick the closest category at 0.9 confidence even for garbage. Confidence is only useful if the model is allowed to admit uncertainty. |\n| **No-invented-facts rule** | The prompt forbids inventing lot numbers, dates, dollar figures, or staff names. If information is missing, the model is told to *ask in the suggested reply* instead. This is the most expensive hallucination class in strata work and the one most prompts get wrong. |\n| **Urgency scoring** | Three-level signal so staff queues can sort by `urgency == \"high\"` first — defined as legal exposure, safety risk, financial deadline, or AGM date. NCAT-bound complaints surface to the top instantly. |\n| **Typed dataclass output** | `TriageResult` with explicit fields. Downstream code (a Laravel job, an n8n branch, a Slack message) reads `result.urgency` not `result[\"urgency\"]`. |\n| **Three layers of error handling** | (1) Empty input short-circuits before the API call. (2) `RateLimitError`, `APIConnectionError`, `APIError` each map to a distinct non-zero exit code so an orchestrator can decide whether to retry. (3) Malformed JSON from the model raises `RuntimeError` with a truncated preview. |\n| **Two output modes** | `--json` for machine consumers (pipelines), no flag for a formatted human-readable box for staff use at the terminal. |\n| **PII-aware flags** | `contains_pii` flag is raised so downstream automation can mask, encrypt, or restrict the record per the firm's data-handling policy. |\n| **Designed to plug in** | One function deep. Wraps trivially into a Laravel queued job, an n8n HTTP node, an IMAP listener, a WordPress hook target, or a Zapier \"Run Command\" node. |\n| **Example enquiries shipped** | `examples/new_client.txt`, `examples/complaint.txt`, `examples/gibberish.txt` — covers the happy path and the spam edge-case so a reviewer can verify end-to-end in under a minute. |\n\n---\n\n### `/// PROJECT LAYOUT`\n\n```\nstrata-enquiry-triage/\n├── triage.py                  single-file CLI — pipeline, prompt, formatter\n├── pyproject.toml             ruff + pytest config\n├── requirements.txt           anthropic + python-dotenv, that's it\n├── .env.example               ANTHROPIC_API_KEY scaffold\n├── tests/                     pytest — 19 tests, Anthropic client mocked\n│   ├── conftest.py            fake-client fixture + canonical payload\n│   └── test_triage.py         prompt parity · short-circuits · parser ·\n│                              error paths · render · file/stdin input\n├── web/                       live demo (deployed to GitHub Pages)\n│   ├── index.html             brutalist single-page UI\n│   ├── style.css              cream / ink / acid-lime tokens, dark mode\n│   ├── app.js                 DOM glue — ES module\n│   ├── triage-core.js         pure logic — system prompt, parser, fetch\n│   └── triage-core.test.mjs   node --test — 14 tests including Python↔JS\n│                              prompt-parity drift guard\n├── examples/\n│   ├── new_client.txt         realistic AU committee quote request\n│   ├── complaint.txt          urgent ceiling-leak complaint with NCAT threat\n│   └── gibberish.txt          edge case — should return spam_or_unclear\n├── .github/\n│   ├── workflows/ci.yml       ruff · pytest · node --test · Pages deploy\n│   └── FUNDING.yml\n└── assets-readme/             brutalist banner SVGs (light + dark)\n```\n\n---\n\n### `/// LIVE DEMO`\n\nThe web app at **[hatimhtm.github.io/strata-enquiry-triage](https://hatimhtm.github.io/strata-enquiry-triage/)** is the same logic as the CLI, running entirely in your browser. You paste your own Anthropic API key (stored in `localStorage`, sent only to `api.anthropic.com` via the official `anthropic-dangerous-direct-browser-access` header — no backend, no server logs) and try the triage against your own enquiries or the four bundled samples.\n\nThe web layer shares its system prompt and category enum with `triage.py` through a Node-importable `triage-core.js` module, and the CI has a drift-guard test that fails the build if the two ever fall out of lock-step.\n\n---\n\n### `/// LOCAL DEV`\n\n```bash\ngit clone https://github.com/hatimhtm/strata-enquiry-triage.git\ncd strata-enquiry-triage\npython3 -m venv .venv \u0026\u0026 source .venv/bin/activate\npip install -r requirements.txt\ncp .env.example .env          # then paste your ANTHROPIC_API_KEY\nexport $(cat .env | xargs)\n\n# inline text\npython triage.py --text \"Hi, I'd like a quote for managing a 40-lot building in Bondi.\"\n\n# from a file\npython triage.py --file examples/complaint.txt\n\n# from a pipe\ncat examples/new_client.txt | python triage.py\n\n# raw JSON for piping into another system\npython triage.py --file examples/new_client.txt --json\n```\n\n**Run the web demo locally:**\n\n```bash\ncd web \u0026\u0026 python3 -m http.server 8000\nopen http://localhost:8000\n```\n\n**Run the test suites:**\n\n```bash\n# Python — 19 tests, Anthropic client mocked, no network calls\npip install pytest ruff\npytest -ra\n\n# Web — 14 tests, node --test (no framework, no install step)\nnode --test web/triage-core.test.mjs\n\n# Lint (matches CI)\nruff check triage.py tests/\nruff format --check triage.py tests/\n```\n\nSample output for `examples/complaint.txt`:\n\n```\n────────────────────────────────────────────────────────────────\n  ENQUIRY  URGENT — water leak in Lot 14, no response for [...]\n────────────────────────────────────────────────────────────────\n  Category   : complaint  (confidence 0.94)\n  Urgency    : high\n  Intent     : Resolve a recurring ceiling leak before escalating to NCAT.\n  Flags      : contains_pii\n  Action     : Escalate to the building manager today; log NCAT-risk note.\n\n  ── Suggested reply ──\n  Hi Sarah, thank you for getting in touch and apologies for the delay\n  in our response. We are treating this as urgent...\n────────────────────────────────────────────────────────────────\n```\n\n---\n\n### `/// TEST COVERAGE`\n\n| Layer | Tool | What it covers |\n|---|---|---|\n| **Python core** | `pytest` (19 tests) | Closed-enum shape, prompt-rule presence, `TriageResult.from_dict` defaults + type coercion, empty/whitespace short-circuit (no API call), happy-path JSON parsing, system-prompt routing, markdown-fence tolerance (both `\\`\\`\\`json` and bare `\\`\\`\\``), invalid-JSON error path, render formatting, file + inline input. |\n| **Web core** | `node --test` (14 tests) | Same closed-enum + prompt-rule guards, fence stripping in JS, normalisation defaults, fake-`fetch` happy path with header + body assertions, 401 error path, non-JSON error path, **Python↔JS prompt parity drift guard** (reads `triage.py` from the JS test and asserts the load-bearing rules match). |\n| **CI** | GitHub Actions | Two parallel jobs (Python + Web), then a third deploy job that uploads `web/` to GitHub Pages on push-to-main. |\n\nThe drift-guard test is the most interesting one — if the Python `SYSTEM_PROMPT` is updated without the JS one, or vice versa, the CI build fails. This is the kind of cross-language sync bug that's easy to ship and impossible to debug after the fact.\n\n---\n\n### `/// DESIGN DECISIONS`\n\n**Why a CLI and not a web UI.** The brief explicitly allowed CLI output and asked for a *small* working tool. A single-file CLI is the smallest end-to-end demo, and it's the easiest thing to plug into a larger workflow — Laravel `Process::run`, n8n \"Execute Command\", an IMAP cron job, a WordPress webhook handler. A web UI would have been more visible and less useful.\n\n**Why Claude Sonnet 4.5.** Strong instruction-following on structured JSON, ~3× cheaper per token than GPT-4-class models for similar classification quality, and the Anthropic SDK has clean typed errors (`RateLimitError`, `APIConnectionError`) which lets the CLI return meaningful exit codes for orchestrators. For a non-conversational classification task this is the right cost/quality point.\n\n**Prompt design — three deliberate choices.**\n\n1. *Closed category list in the system prompt.* Free-text labels are why so many classification prompts run at ~60% accuracy in production — half the failures are the model returning `Bug Report` while the consumer expects `bug`. The enum is enforced in prose; the JSON-schema-style instruction makes the model treat it as the only valid set.\n2. *Explicit `spam_or_unclear` category + an instruction to lower confidence on ambiguous inputs.* Without this the model picks the closest category at 0.9 confidence even for garbage. Confidence only earns its place if the model is allowed to admit uncertainty.\n3. *No-invented-facts rule + an instruction to ask in the suggested reply when info is missing.* Otherwise the model fills in plausible lot numbers and dates, which is the worst hallucination class in this domain because it *looks* correct.\n\n**Error handling — three layers.**\n1. Empty / whitespace input short-circuits with a synthesised result, no API call burned.\n2. Anthropic SDK exceptions each map to a distinct exit code: `3` for transient (rate limit, connection) so an orchestrator retries, `4` for parse failure so it doesn't.\n3. Malformed JSON from the model raises `RuntimeError` with a truncated preview of the bad output — easier to debug than \"something failed.\"\n\n**No retries on the model call.** Sonnet returns valid JSON ≈99.9% of the time with this prompt shape. If it fails twice the input is almost certainly the problem; better to surface the error and let a human look than burn budget on five retries.\n\n---\n\n### `/// AUTOMATION POTENTIAL`\n\nThe CLI is intentionally one function deep so it can become:\n\n- **A Laravel queued job.** `Process::run(['python', 'triage.py', '--json'], input: $email-\u003ebody)`, parse JSON, write to the `enquiries` table, fire a Slack webhook on `urgency == \"high\"`. Horizon handles retries and concurrency.\n- **An n8n / Make node.** \"Execute Command\" → JSONPath extractors → branch on `urgency`. The whole flow is six nodes.\n- **An IMAP listener.** Poll `enquiries@strata...` every 60 seconds, triage each new message, leave the suggested reply as an Outlook draft for staff review. Hardest part is the IMAP polling; the AI layer is unchanged.\n- **A WordPress webhook target.** Gravity Forms / Fluent Forms `fluentform/submission_inserted` hook POSTs the payload to a small Flask wrapper that calls this CLI and returns the JSON. Staff get the notification via `wp_mail()`.\n- **A Zapier \"Run Command\" step.** For non-technical operators who want to own the workflow themselves.\n\n---\n\n### `/// LICENSE`\n\n[MIT](LICENSE). Fork it, swap the system prompt for your own taxonomy, point it at any LLM provider (the SDK call is the only thing that changes), and re-skin it for support tickets, lead routing, intake forms, or any other \"one message in → structured action out\" workflow.\n\n---\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://hatimelhassak.is-a.dev\"\u003e\u003cimg src=\"https://img.shields.io/badge/PORTFOLIO-1A1A1A?style=for-the-badge\u0026logo=vercel\u0026logoColor=CCFF00\" alt=\"Portfolio\" /\u003e\u003c/a\u003e\n  \u003ca href=\"https://cal.com/hatimelhassak/engineering-discovery\"\u003e\u003cimg src=\"https://img.shields.io/badge/BOOK_A_CALL-CCFF00?style=for-the-badge\u0026logo=googlecalendar\u0026logoColor=1A1A1A\" alt=\"Book a call\" /\u003e\u003c/a\u003e\n  \u003ca href=\"https://www.linkedin.com/in/hatim-elhassak/\"\u003e\u003cimg src=\"https://img.shields.io/badge/LINKEDIN-1A1A1A?style=for-the-badge\u0026logo=linkedin\u0026logoColor=CCFF00\" alt=\"LinkedIn\" /\u003e\u003c/a\u003e\n  \u003ca href=\"mailto:hatimelhassak.official@gmail.com\"\u003e\u003cimg src=\"https://img.shields.io/badge/EMAIL-1A1A1A?style=for-the-badge\u0026logo=gmail\u0026logoColor=CCFF00\" alt=\"Email\" /\u003e\u003c/a\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003ccode\u003e///\u0026nbsp;\u0026nbsp;OPEN FOR NEW WORK\u0026nbsp;\u0026nbsp;///\u0026nbsp;\u0026nbsp;CONTRACT \u0026amp; FREELANCE\u0026nbsp;\u0026nbsp;///\u0026nbsp;\u0026nbsp;REMOTE WORLDWIDE\u0026nbsp;\u0026nbsp;///\u003c/code\u003e\n\u003c/p\u003e\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhatimhtm%2Fstrata-enquiry-triage","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fhatimhtm%2Fstrata-enquiry-triage","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhatimhtm%2Fstrata-enquiry-triage/lists"}