Skip to content

Commit 32557fc

Browse files
docs: office-assistant MVP example workflows (#166)
1 parent bbdaf9e commit 32557fc

4 files changed

Lines changed: 344 additions & 0 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
---
2+
title: Office-assistant MVP (meeting notes + Q&A)
3+
---
4+
5+
A minimal "office assistant" built from two manually-run workflows and a Postgres
6+
knowledge base:
7+
8+
- **`office-assistant-ingest-meeting`** — paste a meeting transcript; an LLM
9+
summarizes it and extracts action items, the notes are stored in the KB, a Jira
10+
issue is opened for the action items, and the summary is posted to Teams.
11+
- **`office-assistant-ask`** — ask a question; the KB is full-text searched for
12+
the most relevant notes and an LLM synthesizes a grounded, cited answer.
13+
14+
Cross-run state lives in Postgres, so ingesting builds up a corpus that asking
15+
reads back. Both workflows are in
16+
[`packages/site/src/content/examples/`](https://github.com/dvflw/mantle/tree/main/packages/site/src/content/examples).
17+
18+
## Prerequisites
19+
20+
Create these credentials (`mantle secrets create`) and the KB schema:
21+
22+
| Credential name | Type | Used for |
23+
| --------------- | ---- | -------- |
24+
| `openai` | `openai` | `ai/completion` (summarize + answer) |
25+
| `kb-db` | `postgres` | The knowledge-base database |
26+
| `jira` | `basic` | `jira/create_issue` (email + API token) |
27+
| `teams` | `generic` | `teams/send_message` (incoming-webhook URL) |
28+
29+
> **Using Claude via Bedrock instead of OpenAI:** in both `ai/completion` steps,
30+
> add `provider: bedrock` and a `region:` to `params`, point `credential:` at an
31+
> `aws` credential, and set `model:` to a Bedrock model ID. `provider` defaults
32+
> to `openai`, so changing only `credential`/`model` still sends an
33+
> OpenAI-format request.
34+
35+
Apply the KB schema (pure Postgres full-text search, no extensions) from
36+
[`office-assistant-kb-schema.sql`](https://github.com/dvflw/mantle/blob/main/packages/site/src/content/examples/office-assistant-kb-schema.sql):
37+
38+
```bash
39+
psql "$KB_DATABASE_URL" -f packages/site/src/content/examples/office-assistant-kb-schema.sql
40+
```
41+
42+
Then apply both workflows:
43+
44+
```bash
45+
mantle apply office-assistant-ingest-meeting.yaml
46+
mantle apply office-assistant-ask.yaml
47+
```
48+
49+
## Ingesting a meeting
50+
51+
Put the transcript in a values file (a YAML block scalar handles long,
52+
multi-line text cleanly — there is no input size cap on manual runs):
53+
54+
```yaml
55+
# meeting.values.yaml
56+
inputs:
57+
title: "Q3 strategy sync"
58+
meeting_date: "2026-07-01"
59+
attendees: "Michael, CTO, Team A lead"
60+
transcript: |
61+
<paste the full transcript here — as many lines as you like>
62+
```
63+
64+
```bash
65+
mantle run office-assistant-ingest-meeting --values meeting.values.yaml
66+
```
67+
68+
The run summarizes the transcript, stores the note, opens a Jira action-item
69+
issue (skipped if the LLM found none), and posts the summary to Teams.
70+
71+
## Asking a question
72+
73+
```bash
74+
mantle run office-assistant-ask \
75+
--input question="Who else is working with client C?" \
76+
--output json
77+
```
78+
79+
The `search` step ranks matching notes with `websearch_to_tsquery`, and the
80+
`answer` step's `output.text` is the grounded answer (the CLI prints step
81+
outputs with `--output json` or `-v`). To send the answer somewhere instead of
82+
reading it from the CLI, add a final `teams/send_message` step.
83+
84+
## What you own, and the caveats
85+
86+
This is a deliberately small v0 that fits what the engine does today. Known
87+
trade-offs:
88+
89+
- **Retrieval is full-text search, not semantic.** The KB uses a Postgres
90+
`tsvector`; there are no embeddings. It's the simplest thing that works
91+
end-to-end with stock connectors. A native embeddings + vector-store retrieval
92+
layer is tracked by [#153](https://github.com/dvflw/mantle/issues/153); the
93+
schema comment shows the upgrade path.
94+
- **One Jira issue per meeting**, not one per action item. The engine has no
95+
loop / `for_each` construct, so the workflow opens a single checklist issue
96+
rather than fanning out.
97+
- **Manual transcription.** You paste a transcript; the bot does not join
98+
meetings or transcribe audio (tracked by
99+
[#154](https://github.com/dvflw/mantle/issues/154)).
100+
- **Request/response, not a live Teams chat.** You ask via the CLI (or API) and
101+
optionally post the answer out; there is no conversational in-Teams bot
102+
(tracked by [#155](https://github.com/dvflw/mantle/issues/155)).
103+
- **Confluence / GitHub actions** aren't included here but drop in as extra
104+
`http/request` steps (or the native `jira`/`linear` connectors already used).
105+
106+
See [#161](https://github.com/dvflw/mantle/issues/161) for the full MVP write-up
107+
and how these pieces map to the larger office-assistant vision.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: office-assistant-ask
2+
description: >
3+
Answer a question from the meeting-notes knowledge base. Full-text search the
4+
Postgres KB for the most relevant notes, then have an LLM synthesize a grounded
5+
answer that cites the meetings it used. Pair with
6+
office-assistant-ingest-meeting.yaml. Run it and read the answer from the CLI
7+
output (mantle run office-assistant-ask --values q.yaml --output json).
8+
9+
inputs:
10+
question:
11+
type: string
12+
description: The question to answer from company meeting notes
13+
14+
steps:
15+
# 1. Retrieve the top matching notes via weighted full-text ranking.
16+
# websearch_to_tsquery parses natural-language queries (quotes, OR, -term).
17+
- name: search
18+
action: postgres/query
19+
credential: kb-db
20+
timeout: "15s"
21+
params:
22+
query: >
23+
SELECT title,
24+
to_char(meeting_date, 'YYYY-MM-DD') AS meeting_date,
25+
attendees,
26+
summary,
27+
action_items
28+
FROM meeting_notes
29+
WHERE search @@ websearch_to_tsquery('english', $1)
30+
ORDER BY ts_rank(search, websearch_to_tsquery('english', $1)) DESC
31+
LIMIT 5
32+
args:
33+
- "{{ inputs.question }}"
34+
35+
# 2. Synthesize an answer grounded strictly in the retrieved notes.
36+
- name: answer
37+
action: ai/completion
38+
credential: openai
39+
depends_on:
40+
- search
41+
timeout: "60s"
42+
params:
43+
model: gpt-4o
44+
system_prompt: >
45+
You answer questions using ONLY the provided meeting notes. Cite the
46+
meeting titles you draw from. If the notes do not contain the answer,
47+
say so plainly rather than guessing.
48+
prompt: >
49+
Question: {{ inputs.question }}
50+
51+
Meeting notes (JSON array, most relevant first):
52+
{{ jsonEncode(steps['search'].output.rows) }}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
name: office-assistant-ingest-meeting
2+
description: >
3+
Turn a pasted meeting transcript into structured notes. An LLM writes a summary
4+
and extracts action items; the notes are stored in a Postgres full-text
5+
knowledge base; a single Jira issue is opened for the action items; and the
6+
summary is posted to Teams. Pair with office-assistant-ask.yaml for retrieval.
7+
Requires the schema in office-assistant-kb-schema.sql.
8+
9+
inputs:
10+
title:
11+
type: string
12+
description: Meeting title
13+
meeting_date:
14+
type: string
15+
description: "Meeting date, YYYY-MM-DD (optional)"
16+
default: ""
17+
attendees:
18+
type: string
19+
description: "Comma-separated attendee names (optional)"
20+
default: ""
21+
transcript:
22+
type: string
23+
description: The full meeting transcript — paste it in via a --values file
24+
25+
steps:
26+
# 1. Summarize the transcript and extract structured action items.
27+
- name: summarize
28+
action: ai/completion
29+
credential: openai
30+
timeout: "90s"
31+
params:
32+
model: gpt-4o
33+
system_prompt: >
34+
You are a meeting-notes assistant. From a raw transcript produce: a
35+
concise summary; a list of concrete action items, each with a short
36+
title, a one-line description, and the owner if one was named; a
37+
plain-text rendering of those same action items, one per line as
38+
"Title — description (owner)"; and the key topics discussed. Use only
39+
information present in the transcript.
40+
prompt: >
41+
Meeting: {{ inputs.title }}
42+
Attendees: {{ inputs.attendees }}
43+
44+
Transcript:
45+
{{ inputs.transcript }}
46+
output_schema:
47+
type: object
48+
properties:
49+
summary:
50+
type: string
51+
action_items:
52+
type: array
53+
items:
54+
type: object
55+
properties:
56+
title:
57+
type: string
58+
# description/owner are nullable rather than optional: OpenAI
59+
# strict structured output requires every declared property to
60+
# appear in `required`, so absent values must be expressible as
61+
# null instead of being omitted from the schema.
62+
description:
63+
type:
64+
- string
65+
- "null"
66+
owner:
67+
type:
68+
- string
69+
- "null"
70+
required:
71+
- title
72+
- description
73+
- owner
74+
additionalProperties: false
75+
action_items_text:
76+
type: string
77+
topics:
78+
type: array
79+
items:
80+
type: string
81+
required:
82+
- summary
83+
- action_items
84+
- action_items_text
85+
- topics
86+
additionalProperties: false
87+
88+
# 2. Persist the note into the knowledge base (see office-assistant-kb-schema.sql).
89+
# action_items and topics are stored as JSONB; the search tsvector is
90+
# generated by Postgres from title/summary/transcript.
91+
- name: store-note
92+
action: postgres/query
93+
credential: kb-db
94+
depends_on:
95+
- summarize
96+
timeout: "15s"
97+
params:
98+
# ON CONFLICT makes re-ingesting the same transcript a no-op (dedupe_key
99+
# is md5(transcript)); rows_affected is then 0 and the steps below skip.
100+
query: >
101+
INSERT INTO meeting_notes
102+
(title, meeting_date, attendees, summary, action_items, topics, transcript)
103+
VALUES ($1, NULLIF($2, '')::date, $3, $4, $5::jsonb, $6::jsonb, $7)
104+
ON CONFLICT (dedupe_key) DO NOTHING
105+
args:
106+
- "{{ inputs.title }}"
107+
- "{{ inputs.meeting_date }}"
108+
- "{{ inputs.attendees }}"
109+
- "{{ steps['summarize'].output.json.summary }}"
110+
- "{{ jsonEncode(steps['summarize'].output.json.action_items) }}"
111+
- "{{ jsonEncode(steps['summarize'].output.json.topics) }}"
112+
- "{{ inputs.transcript }}"
113+
114+
# 3. Open one Jira issue capturing the action items (skipped if there are none).
115+
# Per-item fan-out would need a loop construct, which the engine does not
116+
# yet have — a single checklist issue is the clean one-step equivalent.
117+
- name: create-action-items
118+
action: jira/create_issue
119+
credential: jira
120+
depends_on:
121+
- summarize
122+
- store-note
123+
if: "steps['store-note'].output.rows_affected > 0 && size(steps['summarize'].output.json.action_items) > 0"
124+
params:
125+
project_key: OPS
126+
issue_type: Task
127+
summary: "Action items — {{ inputs.title }}"
128+
# jira/create_issue wraps description as a plain-text ADF paragraph, so
129+
# send the plain-text rendering rather than markdown (which would show
130+
# its syntax literally).
131+
description: "{{ steps['summarize'].output.json.action_items_text }}"
132+
133+
# 4. Post the summary to a Teams channel (incoming-webhook credential).
134+
# Skipped on a duplicate re-ingest (store-note inserted no row).
135+
- name: notify-team
136+
action: teams/send_message
137+
credential: teams
138+
depends_on:
139+
- store-note
140+
if: "steps['store-note'].output.rows_affected > 0"
141+
params:
142+
title: "Meeting notes — {{ inputs.title }}"
143+
text: "{{ steps['summarize'].output.json.summary }}"
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
-- Knowledge base for the office-assistant MVP.
2+
--
3+
-- Used by office-assistant-ingest-meeting.yaml (writes) and
4+
-- office-assistant-ask.yaml (reads). This is the v0 retrieval layer: plain
5+
-- Postgres full-text search — no extensions, no embeddings. Point a Mantle
6+
-- credential at this database and reference it as `credential: kb-db` in both
7+
-- workflows.
8+
--
9+
-- Upgrade path: swap the `search` tsvector column + GIN index for a pgvector
10+
-- embedding column and an ANN index once volume justifies semantic search
11+
-- (tracked by dvflw/mantle#153).
12+
13+
CREATE TABLE IF NOT EXISTS meeting_notes (
14+
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
15+
title TEXT NOT NULL,
16+
meeting_date DATE,
17+
attendees TEXT,
18+
summary TEXT NOT NULL,
19+
action_items JSONB NOT NULL DEFAULT '[]'::jsonb,
20+
topics JSONB NOT NULL DEFAULT '[]'::jsonb,
21+
transcript TEXT NOT NULL,
22+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
23+
24+
-- Idempotency key: re-ingesting the same transcript is a no-op via the
25+
-- ON CONFLICT clause in office-assistant-ingest-meeting.yaml, so retries or
26+
-- accidental re-runs don't pollute search results with duplicate notes.
27+
dedupe_key TEXT GENERATED ALWAYS AS (md5(transcript)) STORED,
28+
29+
-- Weighted full-text index: title matches rank highest, then the summary,
30+
-- then the raw transcript. Regenerated automatically on write.
31+
search tsvector GENERATED ALWAYS AS (
32+
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
33+
setweight(to_tsvector('english', coalesce(summary, '')), 'B') ||
34+
setweight(to_tsvector('english', coalesce(transcript, '')), 'C')
35+
) STORED
36+
);
37+
38+
CREATE INDEX IF NOT EXISTS idx_meeting_notes_search
39+
ON meeting_notes USING GIN (search);
40+
41+
CREATE UNIQUE INDEX IF NOT EXISTS idx_meeting_notes_dedupe
42+
ON meeting_notes (dedupe_key);

0 commit comments

Comments
 (0)