-
Notifications
You must be signed in to change notification settings - Fork 0
docs: office-assistant MVP example workflows #166
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,107 @@ | ||||||||||||||
| --- | ||||||||||||||
| 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) | | ||||||||||||||
| | `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): | ||||||||||||||
|
|
||||||||||||||
| ```bash | ||||||||||||||
| psql "$KB_DATABASE_URL" -f packages/site/src/content/examples/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: | | ||||||||||||||
| <paste the full transcript here — as many lines as you like> | ||||||||||||||
| ``` | ||||||||||||||
|
|
||||||||||||||
| ```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 | ||||||||||||||
|
Comment on lines
+74
to
+76
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Align the ask command with the runnable workflow. The paired Proposed fix mantle run office-assistant-ask \
- --input question="Who else is working with client C?" \
+ --values q.yaml \
--output json📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| ``` | ||||||||||||||
|
|
||||||||||||||
| 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. | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| 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 | ||
| timeout: "15s" | ||
| 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) }} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| 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 | ||
| 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 }} | ||
|
|
||
| Transcript: | ||
| {{ inputs.transcript }} | ||
| output_schema: | ||
| type: object | ||
| properties: | ||
| summary: | ||
| type: string | ||
| action_items: | ||
| type: array | ||
| items: | ||
| type: object | ||
| 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 | ||
| - "null" | ||
| owner: | ||
| type: | ||
| - string | ||
| - "null" | ||
| required: | ||
| - title | ||
|
Comment on lines
+70
to
+71
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With the default OpenAI credential used by this workflow, the AI connector sends every Useful? React with 👍 / 👎. |
||
| - description | ||
| - owner | ||
| additionalProperties: false | ||
| action_items_text: | ||
| type: string | ||
| topics: | ||
| type: array | ||
| items: | ||
| type: string | ||
| required: | ||
| - summary | ||
| - action_items | ||
| - action_items_text | ||
| - 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 | ||
| 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 }}" | ||
| - "{{ inputs.attendees }}" | ||
| - "{{ steps['summarize'].output.json.summary }}" | ||
| - "{{ jsonEncode(steps['summarize'].output.json.action_items) }}" | ||
| - "{{ jsonEncode(steps['summarize'].output.json.topics) }}" | ||
| - "{{ inputs.transcript }}" | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| # 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 | ||
| - 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 }}" | ||
| # 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 }}" | ||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| # 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 }}" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| -- 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(), | ||
|
|
||
| -- 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 ( | ||
| 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); | ||
|
|
||
| CREATE UNIQUE INDEX IF NOT EXISTS idx_meeting_notes_dedupe | ||
| ON meeting_notes (dedupe_key); |
Uh oh!
There was an error while loading. Please reload this page.