Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions packages/site/src/content/docs/office-assistant-mvp.md
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
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 office-assistant-ask example is documented as a values-file run, but this snippet switches to --input question=.... If --input is not supported here, the example won’t copy/paste cleanly.

Proposed fix
 mantle run office-assistant-ask \
-  --input question="Who else is working with client C?" \
+  --values q.yaml \
   --output json
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
mantle run office-assistant-ask \
--input question="Who else is working with client C?" \
--output json
mantle run office-assistant-ask \
--values q.yaml \
--output json
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/site/src/content/docs/office-assistant-mvp.md` around lines 68 - 70,
The `office-assistant-ask` example is using `--input question=...`, which may
not match the documented runnable workflow for this command. Update the example
in the office-assistant MVP docs to use the same invocation style as the rest of
the `office-assistant-ask`/`mantle run` workflow, and verify the CLI flags
supported by `office-assistant-ask` so the snippet is copy/pasteable without
relying on unsupported `--input` syntax.

```

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.
52 changes: 52 additions & 0 deletions packages/site/src/content/examples/office-assistant-ask.yaml
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require every action item field in strict schema

With the default OpenAI credential used by this workflow, the AI connector sends every output_schema as response_format.json_schema with strict: true (packages/engine/internal/connector/provider_openai.go:58-65). OpenAI rejects strict object schemas when declared properties are omitted from required; this item schema declares description and owner but only requires title, so the summarize step fails with an API 400 before the meeting can be stored or posted. Make those fields required, or keep them required and allow null values for missing owners/descriptions.

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 }}"
Comment thread
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 }}"

Comment thread
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 }}"
42 changes: 42 additions & 0 deletions packages/site/src/content/examples/office-assistant-kb-schema.sql
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);