From 6d0413e0a9d589df0bdc1d100d56161481b39c62 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 03:37:44 +0000 Subject: [PATCH 1/3] docs: add office-assistant MVP example workflows (#161) Two manually-run workflows plus a Postgres full-text knowledge base that implement the office-assistant MVP: - office-assistant-ingest-meeting.yaml: LLM summarizes a pasted transcript and extracts action items, stores the note in the KB, opens a Jira issue, and posts the summary to Teams. - office-assistant-ask.yaml: full-text searches the KB and has an LLM synthesize a grounded, cited answer. - office-assistant-kb-schema.sql: the tsvector-backed KB schema (v0 retrieval; pgvector upgrade path noted, tracked by #153). - docs/office-assistant-mvp.md: setup, usage, and caveats. Retrieval is Postgres full-text search rather than embeddings so the whole flow runs on stock connectors. Both workflows pass `mantle validate`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WnKgCRfMvFcZAkoVbr8k79 --- .../src/content/docs/office-assistant-mvp.md | 101 +++++++++++++++ .../examples/office-assistant-ask.yaml | 51 ++++++++ .../office-assistant-ingest-meeting.yaml | 122 ++++++++++++++++++ .../examples/office-assistant-kb-schema.sql | 34 +++++ 4 files changed, 308 insertions(+) create mode 100644 packages/site/src/content/docs/office-assistant-mvp.md create mode 100644 packages/site/src/content/examples/office-assistant-ask.yaml create mode 100644 packages/site/src/content/examples/office-assistant-ingest-meeting.yaml create mode 100644 packages/site/src/content/examples/office-assistant-kb-schema.sql diff --git a/packages/site/src/content/docs/office-assistant-mvp.md b/packages/site/src/content/docs/office-assistant-mvp.md new file mode 100644 index 0000000..090de45 --- /dev/null +++ b/packages/site/src/content/docs/office-assistant-mvp.md @@ -0,0 +1,101 @@ +--- +title: Office-assistant MVP (meeting notes + Q&A) +--- + +A minimal "office assistant" built from two manually-run workflows and a Postgres +knowledge base: + +- **`office-assistant-ingest-meeting`** — paste a meeting transcript; an LLM + summarizes it and extracts action items, the notes are stored in the KB, a Jira + issue is opened for the action items, and the summary is posted to Teams. +- **`office-assistant-ask`** — ask a question; the KB is full-text searched for + the most relevant notes and an LLM synthesizes a grounded, cited answer. + +Cross-run state lives in Postgres, so ingesting builds up a corpus that asking +reads back. Both workflows are in +[`packages/site/src/content/examples/`](https://github.com/dvflw/mantle/tree/main/packages/site/src/content/examples). + +## Prerequisites + +Create these credentials (`mantle secrets create`) and the KB schema: + +| Credential name | Type | Used for | +| --------------- | ---- | -------- | +| `openai` | `openai` | `ai/completion` (summarize + answer). Swap the `credential:` and `model:` for `bedrock` to use Claude via Bedrock. | +| `kb-db` | `postgres` | The knowledge-base database | +| `jira` | `basic` | `jira/create_issue` (email + API token) | +| `teams` | `generic` | `teams/send_message` (incoming-webhook URL) | + +Apply the KB schema (pure Postgres full-text search, no extensions) from +[`office-assistant-kb-schema.sql`](https://github.com/dvflw/mantle/blob/main/packages/site/src/content/examples/office-assistant-kb-schema.sql): + +```bash +psql "$KB_DATABASE_URL" -f office-assistant-kb-schema.sql +``` + +Then apply both workflows: + +```bash +mantle apply office-assistant-ingest-meeting.yaml +mantle apply office-assistant-ask.yaml +``` + +## Ingesting a meeting + +Put the transcript in a values file (a YAML block scalar handles long, +multi-line text cleanly — there is no input size cap on manual runs): + +```yaml +# meeting.values.yaml +inputs: + title: "Q3 strategy sync" + meeting_date: "2026-07-01" + attendees: "Michael, CTO, Team A lead" + transcript: | + +``` + +```bash +mantle run office-assistant-ingest-meeting --values meeting.values.yaml +``` + +The run summarizes the transcript, stores the note, opens a Jira action-item +issue (skipped if the LLM found none), and posts the summary to Teams. + +## Asking a question + +```bash +mantle run office-assistant-ask \ + --input question="Who else is working with client C?" \ + --output json +``` + +The `search` step ranks matching notes with `websearch_to_tsquery`, and the +`answer` step's `output.text` is the grounded answer (the CLI prints step +outputs with `--output json` or `-v`). To send the answer somewhere instead of +reading it from the CLI, add a final `teams/send_message` step. + +## What you own, and the caveats + +This is a deliberately small v0 that fits what the engine does today. Known +trade-offs: + +- **Retrieval is full-text search, not semantic.** The KB uses a Postgres + `tsvector`; there are no embeddings. It's the simplest thing that works + end-to-end with stock connectors. A native embeddings + vector-store retrieval + layer is tracked by [#153](https://github.com/dvflw/mantle/issues/153); the + schema comment shows the upgrade path. +- **One Jira issue per meeting**, not one per action item. The engine has no + loop / `for_each` construct, so the workflow opens a single checklist issue + rather than fanning out. +- **Manual transcription.** You paste a transcript; the bot does not join + meetings or transcribe audio (tracked by + [#154](https://github.com/dvflw/mantle/issues/154)). +- **Request/response, not a live Teams chat.** You ask via the CLI (or API) and + optionally post the answer out; there is no conversational in-Teams bot + (tracked by [#155](https://github.com/dvflw/mantle/issues/155)). +- **Confluence / GitHub actions** aren't included here but drop in as extra + `http/request` steps (or the native `jira`/`linear` connectors already used). + +See [#161](https://github.com/dvflw/mantle/issues/161) for the full MVP write-up +and how these pieces map to the larger office-assistant vision. diff --git a/packages/site/src/content/examples/office-assistant-ask.yaml b/packages/site/src/content/examples/office-assistant-ask.yaml new file mode 100644 index 0000000..46a7e22 --- /dev/null +++ b/packages/site/src/content/examples/office-assistant-ask.yaml @@ -0,0 +1,51 @@ +name: office-assistant-ask +description: > + Answer a question from the meeting-notes knowledge base. Full-text search the + Postgres KB for the most relevant notes, then have an LLM synthesize a grounded + answer that cites the meetings it used. Pair with + office-assistant-ingest-meeting.yaml. Run it and read the answer from the CLI + output (mantle run office-assistant-ask --values q.yaml --output json). + +inputs: + question: + type: string + description: The question to answer from company meeting notes + +steps: + # 1. Retrieve the top matching notes via weighted full-text ranking. + # websearch_to_tsquery parses natural-language queries (quotes, OR, -term). + - name: search + action: postgres/query + credential: kb-db + params: + query: > + SELECT title, + to_char(meeting_date, 'YYYY-MM-DD') AS meeting_date, + attendees, + summary, + action_items + FROM meeting_notes + WHERE search @@ websearch_to_tsquery('english', $1) + ORDER BY ts_rank(search, websearch_to_tsquery('english', $1)) DESC + LIMIT 5 + args: + - "{{ inputs.question }}" + + # 2. Synthesize an answer grounded strictly in the retrieved notes. + - name: answer + action: ai/completion + credential: openai + depends_on: + - search + timeout: "60s" + params: + model: gpt-4o + system_prompt: > + You answer questions using ONLY the provided meeting notes. Cite the + meeting titles you draw from. If the notes do not contain the answer, + say so plainly rather than guessing. + prompt: > + Question: {{ inputs.question }} + + Meeting notes (JSON array, most relevant first): + {{ jsonEncode(steps['search'].output.rows) }} diff --git a/packages/site/src/content/examples/office-assistant-ingest-meeting.yaml b/packages/site/src/content/examples/office-assistant-ingest-meeting.yaml new file mode 100644 index 0000000..7d39520 --- /dev/null +++ b/packages/site/src/content/examples/office-assistant-ingest-meeting.yaml @@ -0,0 +1,122 @@ +name: office-assistant-ingest-meeting +description: > + Turn a pasted meeting transcript into structured notes. An LLM writes a summary + and extracts action items; the notes are stored in a Postgres full-text + knowledge base; a single Jira issue is opened for the action items; and the + summary is posted to Teams. Pair with office-assistant-ask.yaml for retrieval. + Requires the schema in office-assistant-kb-schema.sql. + +inputs: + title: + type: string + description: Meeting title + meeting_date: + type: string + description: "Meeting date, YYYY-MM-DD (optional)" + default: "" + attendees: + type: string + description: "Comma-separated attendee names (optional)" + default: "" + transcript: + type: string + description: The full meeting transcript — paste it in via a --values file + +steps: + # 1. Summarize the transcript and extract structured action items. + - name: summarize + action: ai/completion + credential: openai + timeout: "90s" + params: + model: gpt-4o + system_prompt: > + You are a meeting-notes assistant. From a raw transcript produce: a + concise summary; a list of concrete action items, each with a short + title, a one-line description, and the owner if one was named; a + markdown checklist of those same action items; and the key topics + discussed. Use only information present in the transcript. + prompt: > + Meeting: {{ inputs.title }} + Attendees: {{ inputs.attendees }} + + Transcript: + {{ inputs.transcript }} + output_schema: + type: object + properties: + summary: + type: string + action_items: + type: array + items: + type: object + properties: + title: + type: string + description: + type: string + owner: + type: string + required: + - title + additionalProperties: false + action_items_markdown: + type: string + topics: + type: array + items: + type: string + required: + - summary + - action_items + - action_items_markdown + - topics + additionalProperties: false + + # 2. Persist the note into the knowledge base (see office-assistant-kb-schema.sql). + # action_items and topics are stored as JSONB; the search tsvector is + # generated by Postgres from title/summary/transcript. + - name: store-note + action: postgres/query + credential: kb-db + depends_on: + - summarize + params: + query: > + INSERT INTO meeting_notes + (title, meeting_date, attendees, summary, action_items, topics, transcript) + VALUES ($1, NULLIF($2, '')::date, $3, $4, $5::jsonb, $6::jsonb, $7) + args: + - "{{ inputs.title }}" + - "{{ inputs.meeting_date }}" + - "{{ inputs.attendees }}" + - "{{ steps['summarize'].output.json.summary }}" + - "{{ jsonEncode(steps['summarize'].output.json.action_items) }}" + - "{{ jsonEncode(steps['summarize'].output.json.topics) }}" + - "{{ inputs.transcript }}" + + # 3. Open one Jira issue capturing the action items (skipped if there are none). + # Per-item fan-out would need a loop construct, which the engine does not + # yet have — a single checklist issue is the clean one-step equivalent. + - name: create-action-items + action: jira/create_issue + credential: jira + depends_on: + - summarize + if: "size(steps['summarize'].output.json.action_items) > 0" + params: + project_key: OPS + issue_type: Task + summary: "Action items — {{ inputs.title }}" + description: "{{ steps['summarize'].output.json.action_items_markdown }}" + + # 4. Post the summary to a Teams channel (incoming-webhook credential). + - name: notify-team + action: teams/send_message + credential: teams + depends_on: + - store-note + params: + title: "Meeting notes — {{ inputs.title }}" + text: "{{ steps['summarize'].output.json.summary }}" diff --git a/packages/site/src/content/examples/office-assistant-kb-schema.sql b/packages/site/src/content/examples/office-assistant-kb-schema.sql new file mode 100644 index 0000000..aaeee20 --- /dev/null +++ b/packages/site/src/content/examples/office-assistant-kb-schema.sql @@ -0,0 +1,34 @@ +-- Knowledge base for the office-assistant MVP. +-- +-- Used by office-assistant-ingest-meeting.yaml (writes) and +-- office-assistant-ask.yaml (reads). This is the v0 retrieval layer: plain +-- Postgres full-text search — no extensions, no embeddings. Point a Mantle +-- credential at this database and reference it as `credential: kb-db` in both +-- workflows. +-- +-- Upgrade path: swap the `search` tsvector column + GIN index for a pgvector +-- embedding column and an ANN index once volume justifies semantic search +-- (tracked by dvflw/mantle#153). + +CREATE TABLE IF NOT EXISTS meeting_notes ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + title TEXT NOT NULL, + meeting_date DATE, + attendees TEXT, + summary TEXT NOT NULL, + action_items JSONB NOT NULL DEFAULT '[]'::jsonb, + topics JSONB NOT NULL DEFAULT '[]'::jsonb, + transcript TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- Weighted full-text index: title matches rank highest, then the summary, + -- then the raw transcript. Regenerated automatically on write. + search tsvector GENERATED ALWAYS AS ( + setweight(to_tsvector('english', coalesce(title, '')), 'A') || + setweight(to_tsvector('english', coalesce(summary, '')), 'B') || + setweight(to_tsvector('english', coalesce(transcript, '')), 'C') + ) STORED +); + +CREATE INDEX IF NOT EXISTS idx_meeting_notes_search + ON meeting_notes USING GIN (search); From bd04e1d319f19ba529815252e44487b354d52cb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 03:44:54 +0000 Subject: [PATCH 2/3] docs: fix OpenAI strict-schema and Bedrock note in office-assistant MVP Addresses PR review feedback: - ingest-meeting: OpenAI structured output is sent with strict:true, which requires every declared property to appear in `required`. The action_items item schema listed description/owner but only required title, so the summarize step would 400. Make them required and nullable (type: [string, null]) so absent values are expressible. - docs: the Bedrock swap needs `provider: bedrock` (+ region); provider defaults to openai, so changing only credential/model still sends an OpenAI-format request. Corrected the guidance. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WnKgCRfMvFcZAkoVbr8k79 --- .../site/src/content/docs/office-assistant-mvp.md | 8 +++++++- .../examples/office-assistant-ingest-meeting.yaml | 14 ++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/site/src/content/docs/office-assistant-mvp.md b/packages/site/src/content/docs/office-assistant-mvp.md index 090de45..5bd6c1d 100644 --- a/packages/site/src/content/docs/office-assistant-mvp.md +++ b/packages/site/src/content/docs/office-assistant-mvp.md @@ -21,11 +21,17 @@ Create these credentials (`mantle secrets create`) and the KB schema: | Credential name | Type | Used for | | --------------- | ---- | -------- | -| `openai` | `openai` | `ai/completion` (summarize + answer). Swap the `credential:` and `model:` for `bedrock` to use Claude via Bedrock. | +| `openai` | `openai` | `ai/completion` (summarize + answer) | | `kb-db` | `postgres` | The knowledge-base database | | `jira` | `basic` | `jira/create_issue` (email + API token) | | `teams` | `generic` | `teams/send_message` (incoming-webhook URL) | +> **Using Claude via Bedrock instead of OpenAI:** in both `ai/completion` steps, +> add `provider: bedrock` and a `region:` to `params`, point `credential:` at an +> `aws` credential, and set `model:` to a Bedrock model ID. `provider` defaults +> to `openai`, so changing only `credential`/`model` still sends an +> OpenAI-format request. + Apply the KB schema (pure Postgres full-text search, no extensions) from [`office-assistant-kb-schema.sql`](https://github.com/dvflw/mantle/blob/main/packages/site/src/content/examples/office-assistant-kb-schema.sql): diff --git a/packages/site/src/content/examples/office-assistant-ingest-meeting.yaml b/packages/site/src/content/examples/office-assistant-ingest-meeting.yaml index 7d39520..73a9dca 100644 --- a/packages/site/src/content/examples/office-assistant-ingest-meeting.yaml +++ b/packages/site/src/content/examples/office-assistant-ingest-meeting.yaml @@ -54,12 +54,22 @@ steps: properties: title: type: string + # description/owner are nullable rather than optional: OpenAI + # strict structured output requires every declared property to + # appear in `required`, so absent values must be expressible as + # null instead of being omitted from the schema. description: - type: string + type: + - string + - "null" owner: - type: string + type: + - string + - "null" required: - title + - description + - owner additionalProperties: false action_items_markdown: type: string From ab1d6fb5769b9e21c1c577eb9b42c67d5611c880 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 03:50:39 +0000 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20address=20review=20=E2=80=94=20dedu?= =?UTF-8?q?pe,=20timeouts,=20plain-text=20Jira,=20schema=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review follow-ups on the office-assistant MVP: - KB schema: add a dedupe_key (md5(transcript)) + unique index; the store-note insert now uses ON CONFLICT DO NOTHING so retries/re-runs don't create duplicate notes that would pollute search. - ingest: gate the Jira and Teams steps on rows_affected > 0 so a duplicate re-ingest is a clean no-op; add a 15s timeout to the DB write (and to the ask search). - ingest: jira/create_issue wraps description as plain-text ADF, so markdown rendered literally — the LLM now emits a plain-text action-item rendering (action_items_text) used as the Jira description. - docs: use the full repo-root path in the psql schema-load command. Kept the `ask` example's `--input question=...` — that flag is supported by `mantle run`. Both workflows still pass `mantle validate`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WnKgCRfMvFcZAkoVbr8k79 --- .../src/content/docs/office-assistant-mvp.md | 2 +- .../examples/office-assistant-ask.yaml | 1 + .../office-assistant-ingest-meeting.yaml | 23 ++++++++++++++----- .../examples/office-assistant-kb-schema.sql | 8 +++++++ 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/site/src/content/docs/office-assistant-mvp.md b/packages/site/src/content/docs/office-assistant-mvp.md index 5bd6c1d..5ff21bc 100644 --- a/packages/site/src/content/docs/office-assistant-mvp.md +++ b/packages/site/src/content/docs/office-assistant-mvp.md @@ -36,7 +36,7 @@ Apply the KB schema (pure Postgres full-text search, no extensions) from [`office-assistant-kb-schema.sql`](https://github.com/dvflw/mantle/blob/main/packages/site/src/content/examples/office-assistant-kb-schema.sql): ```bash -psql "$KB_DATABASE_URL" -f office-assistant-kb-schema.sql +psql "$KB_DATABASE_URL" -f packages/site/src/content/examples/office-assistant-kb-schema.sql ``` Then apply both workflows: diff --git a/packages/site/src/content/examples/office-assistant-ask.yaml b/packages/site/src/content/examples/office-assistant-ask.yaml index 46a7e22..e5ab104 100644 --- a/packages/site/src/content/examples/office-assistant-ask.yaml +++ b/packages/site/src/content/examples/office-assistant-ask.yaml @@ -17,6 +17,7 @@ steps: - name: search action: postgres/query credential: kb-db + timeout: "15s" params: query: > SELECT title, diff --git a/packages/site/src/content/examples/office-assistant-ingest-meeting.yaml b/packages/site/src/content/examples/office-assistant-ingest-meeting.yaml index 73a9dca..9e801c1 100644 --- a/packages/site/src/content/examples/office-assistant-ingest-meeting.yaml +++ b/packages/site/src/content/examples/office-assistant-ingest-meeting.yaml @@ -34,8 +34,9 @@ steps: You are a meeting-notes assistant. From a raw transcript produce: a concise summary; a list of concrete action items, each with a short title, a one-line description, and the owner if one was named; a - markdown checklist of those same action items; and the key topics - discussed. Use only information present in the transcript. + plain-text rendering of those same action items, one per line as + "Title — description (owner)"; and the key topics discussed. Use only + information present in the transcript. prompt: > Meeting: {{ inputs.title }} Attendees: {{ inputs.attendees }} @@ -71,7 +72,7 @@ steps: - description - owner additionalProperties: false - action_items_markdown: + action_items_text: type: string topics: type: array @@ -80,7 +81,7 @@ steps: required: - summary - action_items - - action_items_markdown + - action_items_text - topics additionalProperties: false @@ -92,11 +93,15 @@ steps: credential: kb-db depends_on: - summarize + timeout: "15s" params: + # ON CONFLICT makes re-ingesting the same transcript a no-op (dedupe_key + # is md5(transcript)); rows_affected is then 0 and the steps below skip. query: > INSERT INTO meeting_notes (title, meeting_date, attendees, summary, action_items, topics, transcript) VALUES ($1, NULLIF($2, '')::date, $3, $4, $5::jsonb, $6::jsonb, $7) + ON CONFLICT (dedupe_key) DO NOTHING args: - "{{ inputs.title }}" - "{{ inputs.meeting_date }}" @@ -114,19 +119,25 @@ steps: credential: jira depends_on: - summarize - if: "size(steps['summarize'].output.json.action_items) > 0" + - store-note + if: "steps['store-note'].output.rows_affected > 0 && size(steps['summarize'].output.json.action_items) > 0" params: project_key: OPS issue_type: Task summary: "Action items — {{ inputs.title }}" - description: "{{ steps['summarize'].output.json.action_items_markdown }}" + # jira/create_issue wraps description as a plain-text ADF paragraph, so + # send the plain-text rendering rather than markdown (which would show + # its syntax literally). + description: "{{ steps['summarize'].output.json.action_items_text }}" # 4. Post the summary to a Teams channel (incoming-webhook credential). + # Skipped on a duplicate re-ingest (store-note inserted no row). - name: notify-team action: teams/send_message credential: teams depends_on: - store-note + if: "steps['store-note'].output.rows_affected > 0" params: title: "Meeting notes — {{ inputs.title }}" text: "{{ steps['summarize'].output.json.summary }}" diff --git a/packages/site/src/content/examples/office-assistant-kb-schema.sql b/packages/site/src/content/examples/office-assistant-kb-schema.sql index aaeee20..56468a1 100644 --- a/packages/site/src/content/examples/office-assistant-kb-schema.sql +++ b/packages/site/src/content/examples/office-assistant-kb-schema.sql @@ -21,6 +21,11 @@ CREATE TABLE IF NOT EXISTS meeting_notes ( transcript TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- Idempotency key: re-ingesting the same transcript is a no-op via the + -- ON CONFLICT clause in office-assistant-ingest-meeting.yaml, so retries or + -- accidental re-runs don't pollute search results with duplicate notes. + dedupe_key TEXT GENERATED ALWAYS AS (md5(transcript)) STORED, + -- Weighted full-text index: title matches rank highest, then the summary, -- then the raw transcript. Regenerated automatically on write. search tsvector GENERATED ALWAYS AS ( @@ -32,3 +37,6 @@ CREATE TABLE IF NOT EXISTS meeting_notes ( CREATE INDEX IF NOT EXISTS idx_meeting_notes_search ON meeting_notes USING GIN (search); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_meeting_notes_dedupe + ON meeting_notes (dedupe_key);