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..5ff21bc --- /dev/null +++ b/packages/site/src/content/docs/office-assistant-mvp.md @@ -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: | + +``` + +```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..e5ab104 --- /dev/null +++ b/packages/site/src/content/examples/office-assistant-ask.yaml @@ -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) }} 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..9e801c1 --- /dev/null +++ b/packages/site/src/content/examples/office-assistant-ingest-meeting.yaml @@ -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 + - 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 }}" + + # 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 }}" + + # 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 new file mode 100644 index 0000000..56468a1 --- /dev/null +++ b/packages/site/src/content/examples/office-assistant-kb-schema.sql @@ -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);