zynkr-kms · git:20260826.57828db · 2026-08-26 · sha256 79eb7086c6f357bd

zynkr-kms git:20260826.57828dbA

Immutable. This exact content never changes and is served at /api/v1/blob/79eb7086c6f357bd.

---
name: zynkr-kms
sheetId: "3.02"
description: >-
  The knowledge-curator (WRITE) half of Peter's support loop. After a support /
  inbound-sales ticket is resolved, zynkr-kms reads the thread, learns Peter's actual
  answer, and writes it as a clean, LLM-friendly card into the **Zynkr platform 知識庫**
  (platform.zynkr.ai/kb, via the `zynkr` MCP server) — the single source of truth since the
  2026-07-15 cutover. Core Facts (canonical numbers/policies) become `fact` cards with a
  stable `fact_id`; Q&A answers become `qa` cards that CITE those facts instead of restating
  numbers. It reasons like a KMS specialist (intent taxonomy → which KB section → how to
  structure for retrieval), proposes entries, and only writes after Peter approves.
  /zynkr-support reads the same platform KB, so the drafter gets smarter every week. It also
  GOVERNS BOTH STORES: after every approved write it mirrors the change to the Google Drive
  backup (`[3.2] 客服知識庫 (KMS)`) — one Doc per section plus a full-fidelity JSON snapshot —
  so the knowledge survives a Supabase incident and stays human-readable. The mirror is
  one-way (platform → Drive) and never authoritative. Trigger whenever Peter says
  "/zynkr-kms", "learn from resolved tickets", "update the support KB", "curate the KB",
  "把已回覆的 support 信變成知識庫", "更新知識庫", "整理 KB", "let the KB learn", or hands over
  a resolved support/sales thread and wants the answer captured — and also on "備份知識庫",
  "back up the KB", "sync the KB to Drive", "mirror the KB", "KB 有沒有備份", "reconcile the
  KB mirror". This is the WRITE side; /zynkr-support is the READ side.
category: operations
project: zynkr-kms
platform: claude
status: WIP
author: Peter Tu
input: "Resolved Support/Inbound-Sales Gmail threads (or a single thread Peter specifies); the KB is the Zynkr platform 知識庫 (platform.zynkr.ai/kb) — sections + fact/qa cards, accessed through the `zynkr` MCP server"
process: "Pull resolved, not-yet-ingested threads → confirm they are learnable and extract the question + Peter's answer (PII-stripped) → draft cards through three lenses (intent classification / target section / fact-vs-qa structuring) → propose to Peter for approval → write via mcp__zynkr__create_kb_article / update_kb_article (facts before the qa cards that cite them) → mirror the touched sections + snapshot to the Google Drive backup → mark KMS-ingested"
output: "After Peter's approval, cards are written into the matching platform-KB section; a new intent creates a new section via create_kb_section; the touched sections and the JSON snapshot are re-mirrored to the Google Drive backup; the source thread is marked KMS-ingested; finishes with an added / updated / skipped / mirrored summary"
synergy: ["zynkr-support"]
---

# Zynkr KMS — Support Knowledge Curator (platform KB + Drive mirror)

```bash
npx skills add https://github.com/peter-tu-zynkr/zynkr-skill-builder --skill zynkr-kms
```

`/zynkr-support` *reads* the platform 知識庫 to draft replies. This skill is the other half of the
loop: it **learns from resolved tickets and writes the answers back** so the KB heals and the
drafter stops falling back to `[[NEEDS PETER]]` holding replies.

You think like a **KMS specialist**, not a transcriber. For every resolved thread you reason
through three lenses before anything is written:

1. **Intent taxonomy** — what *kind* of question is this?
2. **Where to write** — which **KB section** does it belong in, and is it a new card or an update?
3. **Structure for the LLM** — a canonical `fact` card, or a `qa` card that cites facts?

The golden rule: **extract, never invent.** KB facts come only from what Peter actually wrote. A
bad KB card silently poisons every future auto-reply, so the bar is high and **nothing enters the
KB without Peter's approval.**

> **This skill governs TWO stores, and the arrow between them only points one way.**
>
> ```
> Zynkr platform KB  ──(mirror)──▶  Google Drive Docs
>    (writable SOT)                  (read-only backup)
> ```
>
> - **Zynkr platform 知識庫** (Supabase-backed, `https://platform.zynkr.ai/kb`, via the `zynkr`
>   MCP server) — the **only writable KB** and the single source of truth. `/zynkr-support`
>   reads this, never the mirror.
> - **Google Drive** `[3.2] 客服知識庫 (KMS)` (`1LpymoVhy4YrxDBi81Sw6CRQQbZAiSLQ6`) — the
>   **backup mirror**: one Doc per section plus a full-fidelity `_KB-SNAPSHOT.json`. Rewritten
>   from the platform on every run (Step 6). Never authoritative, never read back into the KB;
>   a human edit made there is destroyed on the next mirror. If the two disagree, the platform
>   is right.
>
> History: one monolith Google Doc → nested one-doc-per-section Google Docs (2026-06-06) →
> seeded into the platform KB (2026-06-09) → **full cutover 2026-07-15**, after which Drive was
> a frozen archive → **2026-08-26: Drive relit as a live one-way mirror**, closing a 6-week gap
> where 22 cards existed only in Supabase.

---

## Resources you'll use (fixed facts — don't re-derive)

- **KB access** — the **`zynkr` MCP server** (the platform's own MCP at `platform.zynkr.ai`,
  authenticated as Peter's workspace). The tools you'll use:
  - `mcp__zynkr__list_kb_sections` — the live section vocabulary (13 sections; run this first)
  - `mcp__zynkr__search_kb` / `mcp__zynkr__list_kb_articles` — dedupe & lookup
  - `mcp__zynkr__get_kb_article` — fetch one card by uuid **or by `fact_id`** (qa cards resolve
    their cited facts inline)
  - `mcp__zynkr__create_kb_article` / `mcp__zynkr__update_kb_article` — the write pair
  - `mcp__zynkr__create_kb_section` — only for a Peter-approved NEW intent category
- **Write safety** — the write tools are preview-by-default: called without `confirm` they return a
  dry-run. Peter's approval in Step 4 is the real gate; after it, call with `confirm: true`.
- **KB UI** (for links in your summary): `https://platform.zynkr.ai/kb`
- **Google account**: `peter_tu@zynkr.ai` — **MCP server `google-workspace`** for all Gmail tools.
  Gmail is still where tickets live; only the KB moved.
- **Source labels** (nested — use the **full path**):
  - Support: `[3] Operation/[3.8] Support`
  - Inbound Sales: `[2] Sales & consultant/[2.1] Inbound Sales`
- **Ingest-ledger label**: `KMS-ingested` — applied once a thread's answer is in the KB. The
  idempotency record; never re-process a thread that already carries it.
- **Drive mirror** (the backup half — full mechanics in `references/drive-mirror.md`):
  - Folder `[3.2] 客服知識庫 (KMS)` — `1LpymoVhy4YrxDBi81Sw6CRQQbZAiSLQ6`
  - `scripts/kb-drive-mirror.mjs` — renders the KB to Markdown locally (read-only; needs
    `--workspace`, see below). Card bodies stay on disk, out of context.
  - `mcp__google-workspace__update_drive_file` — uploads in place by Doc ID (`source_format: "md"`
    converts Markdown → native Doc and **keeps the Doc ID**)
  - ⚠️ **Peter's workspace is `19881fe1-0081-452e-9141-8ba196e61abe`.** `crm_kb_*` is a shared
    multi-tenant table whose other tenants have `nn`-colliding sections; an unscoped read mirrors
    their cards into Peter's Drive. `mcp__zynkr__list_kb_sections` is already workspace-scoped.
  - ⚠️ The `google-workspace` MCP only accepts local paths under
    `/Users/petertu/.workspace-mcp/attachments/` — stage rendered files there before uploading.
- **Companion files**:
  - `references/intent-taxonomy.md` — intent categories, aliases, and the intent → section map
  - `references/entry-schema.md` — the exact fact-card / qa-card schema (tool-call fields)
  - `references/new-section-playbook.md` — how to add a **new section** for a new intent
  - `references/drive-mirror.md` — the Drive backup: arrow of truth, Doc-ID registry, how to run
    a mirror or a full reconcile, and how to restore from the snapshot

---

## Step 1: Pull the *resolved, not-yet-ingested* queue

This is the **inverse** of `/zynkr-support`: the drafter handles threads Peter *hasn't* replied to;
this skill harvests threads he *has* replied to and that aren't ingested yet.

If Peter handed you a **specific thread** (pasted link or "learn from this one"), use just that —
skip the queue search.

Otherwise search both queues with `mcp__google-workspace__search_gmail_messages`:

```
(label:"[3] Operation/[3.8] Support" OR label:"[2] Sales & consultant/[2.1] Inbound Sales") from:me -label:KMS-ingested newer_than:90d
```

Fallback for website-form sales inquiries answered as fresh emails (only if Peter asks you to sweep
website inquiries too):

```
from:website@zynkr.ai -label:KMS-ingested newer_than:90d
```

Dedupe by thread ID. If nothing comes back, tell Peter the KB is already up to date and stop.
Otherwise list what you found (subject · sender · queue) and say you're reviewing N threads.

---

## Step 2: Confirm the thread is *learnable*, then extract

Call `mcp__google-workspace__get_gmail_thread_content` on each thread ID. A thread is **learnable
only if all three hold**:

1. The **last message is from Peter** (`peter_tu@zynkr.ai` / any zynkr.ai sender) — i.e. he replied.
2. An **earlier inbound carried a real question** from the inquirer.
3. Peter's reply contains a **real answer** — concrete facts, not a holding reply.

**Skip (and do NOT label `KMS-ingested`) when:**
- Peter's last message was a holding reply ("讓我確認一下細節後再回覆您" / "let me get back to you") —
  there's no answer to learn yet. Leaving it un-labelled means it gets re-checked next run.
- The thread is spam / out-of-scope / a thank-you with no Q&A content.

For each learnable thread, extract:
- The inquirer's **question(s)** — there may be several; each can become its own card.
- Peter's **committed facts**: pricing, policy, how-to steps, scope, dates he actually stated.
- **Source context**: thread link, the date Peter answered, the inquirer's **first name + company
  only**.

**Guardrails while extracting:**
- **Never invent or "improve" facts.** If Peter only answered part of the question, capture only
  that part; note the unanswered part in your proposal.
- **PII guard**: keep first name + company for context; **strip** the inquirer's email, phone, and
  any attendee-list / roster data. None of it reaches the KB.

---

## Step 3: Apply the three lenses → draft a proposed card

Start by calling `mcp__zynkr__list_kb_sections` once — it returns the live section vocabulary
(slug, title, aliases, section `id`). For each question you extracted:

### Lens 1 — Intent taxonomy
Classify into the best-fit section from `references/intent-taxonomy.md` (`pricing-quoting`,
`course-content`, `scheduling-logistics`, `team-training-enterprise`, `technical-howto`,
`access-account`, `refund-policy`, `instructor-profile`, `brand-product-vision`,
`ai-workflow-architecture`, …). If nothing fits well, **propose a new section** — flag it for
Peter; never silently invent one or dump it in `other` without saying so.

> **Style rules are not Q&A.** When Peter gives a **tone / wording / term** rule (e.g.
> `parachute`→「和團隊諮詢、觀察流程」, `discovery call`→「線上會議」), it belongs in the
> **`tone-style`** section: append a row to the 用語對照表 by updating the
> **`term-mapping-table`** fact card (`get_kb_article("term-mapping-table")` → add the row to its
> markdown table → `update_kb_article`). Broader voice rules go in the **`tone-voice-rules`** card.

### Lens 2 — Where to write (which section, new card or update)
- Map the intent → its section `id` from `list_kb_sections`.
- **Dedupe**: run `mcp__zynkr__search_kb` with the question's key terms (zh + EN), and
  `mcp__zynkr__list_kb_articles` filtered by that `section_id` **with `status: "all"`** to scan
  titles (search covers published only; a draft or archived near-duplicate matters too). If a
  near-duplicate exists, propose an **UPDATE / supersede** of that card rather than a second card.
  This matters most for `pricing-quoting` and `refund-policy`, where stale answers are harmful.

### Lens 3 — Structure for the LLM (fact card vs. citing qa card)
The KB has **two card types** (full spec in `references/entry-schema.md`):

- **`fact` card** — canonical, reusable truths whose *numbers/terms live once*: the pricing table,
  refund policy, standard durations. Each has a stable kebab-case **`fact_id`** (e.g.
  `pricing-rates`), unique per workspace.
- **`qa` card** — maps one inquiry to the facts and **cites** them via the `cites` array (bare
  fact ids, e.g. `["pricing-rates"]`) rather than restating the numbers.

**Decide which you're writing.** If the answer rests on a fact that could appear in other answers or
changes over time (price, rate, policy, lead time):
1. Is there already a fact card for it? Check with `get_kb_article("<fact-id-guess>")` and
   `search_kb`. → the qa card just lists it in `cites` and describes the *mapping/logic*, **no
   restated numbers**.
2. No fact yet? → propose a **NEW fact card** (numbers go there) **and** a thin citing qa card.
3. The fact exists but Peter's reply changes it (new rate)? → propose **UPDATE fact** via
   `update_kb_article` — one edit, and every citing card everywhere is current.

Draft each proposed card with these fields (they map 1:1 onto `create_kb_article`):

```
type:        fact | qa
title:       fact → short human title (e.g. 授課費率表)
             qa   → the canonical, normalized question (e.g. 一天 N 小時的 AI 課程如何報價?)
fact_id:     fact only — stable kebab-case id (e.g. pricing-rates)
body_md:     fact → the canonical numbers/terms (table/bullets), incl. explicit 未定範圍 caveats
             qa   → the mapping/logic ("依 FACT:pricing-rates …"), NOT the restated numbers
section_id:  from list_kb_sections
cites:       qa only — bare fact ids this answer rests on
keywords:    bilingual zh-TW + EN synonyms — the retrieval surface, spend effort here
source_url:  Gmail thread permalink
source_note: "<YYYY-MM-DD> · <provenance, e.g. inbound 詢價>"
source_type: fact → core_fact · qa → peter_answer
```

Only inline a hard number in a qa card when it is specific to this ticket and lives in **no** fact
(then preserve it verbatim). `get_kb_article` resolves a qa card's cited facts inline at draft
time — so a cite only works if the fact card **already exists**. Always write/UPDATE the fact first.

---

## Step 4: Propose, then wait for approval (the write gate)

Present **one table** of everything you intend to write — nothing is written yet:

```
Proposed KB changes from N resolved threads:

| # | Action | Section | Card / fact_id | Preview | Flag |
|---|--------|---------|----------------|---------|------|
| 1 | UPDATE fact | core-facts | pricing-rates | 實體 rate → NT$Y | ⚠ pricing change |
| 2 | NEW qa      | pricing-quoting | Q: … | cites pricing-rates | — |
| 3 | NEW fact    | core-facts | refund-policy | (creates canonical fact) | ⚠ new fact |
| 4 | NEW SECTION | (new) + qa | logistics-overseas | create_kb_section + card | ⚠ new category |
```

Then show the **full rendered card** for each proposed entry below the table.

**Always flag for explicit confirmation:**
- ⚠ **Pricing / policy supersession** — "this overwrites the older NT$X answer; confirm the current
  rate before I replace it."
- ⚠ **New section** — proposing a category that doesn't exist yet.
- ⚠ **Ambiguous intent** or **partial answer**.

Peter approves all / edits / rejects per row. **Only approved rows proceed to Step 5.** If he
rejects a thread entirely, do not label it ingested (Step 6).

---

## Step 5: Write approved cards to the platform KB

**Order matters: write/UPDATE the fact card FIRST, then the qa card that cites it** — cites resolve
only against facts that already exist.

### 5a — NEW or UPDATE a fact card
- **NEW fact** — `mcp__zynkr__create_kb_article` with `confirm: true` and:
  `type: "fact"`, `fact_id`, `title`, `body_md`, `section_id` (usually the `core-facts` section —
  style rules go in `tone-style`), `keywords`, `source_url`, `source_note`,
  `source_type: "core_fact"`, `mark_verified: true`.
- **UPDATE a fact** (the high-value path — one edit refreshes every citing card) —
  `mcp__zynkr__update_kb_article` with `confirm: true`, the card's `id` (find it via
  `get_kb_article("<fact_id>")`), only the changed fields, and `mark_verified: true` (version
  auto-bumps).

### 5b — NEW qa card into an existing section
`mcp__zynkr__create_kb_article` with `confirm: true` and:
`type: "qa"`, `title` (the normalized question), `body_md` (the mapping/logic), `section_id`,
`cites` (bare fact ids), `keywords`, `source_url`, `source_note`, `source_type: "peter_answer"`,
`mark_verified: true`.

### 5c — UPDATE / supersede an existing qa card
`mcp__zynkr__update_kb_article` with `confirm: true`, `id` = the existing card's uuid, only the
changed fields (`body_md`, `cites`, `keywords`, `source_url`, `source_note`), `mark_verified: true`.
**Supersede in place — never leave two contradictory cards** the drafter might both retrieve.

### 5d — NEW SECTION (intent has no section yet)
Follow `references/new-section-playbook.md`:
1. `mcp__zynkr__create_kb_section` with `confirm: true` — kebab `slug`, `title`, `title_zh`,
   `description`, bilingual `aliases` (they aid routing/search), an appropriate lucide `icon`
   (`nn` auto-assigns).
2. Then write the card(s) into it per 5a/5b using the returned section `id`.
3. Add the new category to `references/intent-taxonomy.md` so it's first-class next time.

### Verify each write
After every create/update, `mcp__zynkr__get_kb_article` on the returned id — confirm the card
reads back with a non-empty body and (for qa) that every cite resolves to a real fact card. A
dangling cite means you wrote the qa before its fact — fix by writing the fact now.

---

## Step 6: Mirror the change to the Google Drive backup

Every approved write in Step 5 must reach the Drive mirror in the same run — a KB write that
isn't mirrored is exactly the drift this step exists to prevent. Mirror **after** the writes
land (it renders whatever the KB currently holds), and only for the sections you touched.

Full mechanics, the Doc-ID registry, and the restore procedure live in
`references/drive-mirror.md`. The short form:

1. **Render** the touched sections —
   `node scripts/kb-drive-mirror.mjs --out <staging> --workspace <Peter's uuid> --env <.env.local>
   --sections <slug,slug>`. Omit `--sections` for a full reconcile (also refreshes the snapshot,
   the manifest and the 00 INDEX Doc). The script is read-only and prints just a counts summary.
2. **Stage** the rendered files into `/Users/petertu/.workspace-mcp/attachments/kb-mirror/`.
3. **Upload in place** with `mcp__google-workspace__update_drive_file` — `file_id` from the
   registry, `source_format: "md"`, and the AUTO-GENERATED MIRROR banner as `description`. Never
   create a second Doc for a section that already has one.
4. **Refresh `_KB-SNAPSHOT.json`** whenever any card changed — it is the only artifact with
   enough fidelity to restore from (`fact_id`, `cites`, `keywords`, `version`, `source_*`).
5. If Step 5d created a **new section**, the mirror gains a Doc: use
   `mcp__google-workspace__import_to_google_doc` (`create_drive_file` leaves raw markdown, not a
   Doc), then add its ID to the registry in `references/drive-mirror.md`.

**Check before moving on:** the script's `sections` count must match
`mcp__zynkr__list_kb_sections`, and `orphans` must be `0`. A larger `sections` number means the
workspace filter was dropped — stop and fix it rather than uploading.

---

## Step 7: Mark ingested + report

- Resolve the ledger label's **ID**: `mcp__google-workspace__list_gmail_labels` and find
  `KMS-ingested`. If it doesn't exist yet, create it with `manage_gmail_label`
  (`action: "create"`, `name: "KMS-ingested"`) and take the ID from the response.
- Apply it to **every thread whose card was written**:
  `mcp__google-workspace__modify_gmail_message_labels` (or `batch_modify_gmail_message_labels`)
  with the thread's **message ID(s)** (from the Step-1 search results) as `message_ids` and the
  label **ID** (not the name) in `add_label_ids`. Do **not** label skipped or rejected threads.

Then give Peter a single summary:

```
KB updated — N cards from M threads:

✅ Added (X):
  - [section] — Q: … — [sender / company]
✏️  Updated / superseded (Y):
  - [section] — card/fact_id: … — replaced older answer (e.g. pricing)
🆕 New sections created (Z):
  - [slug — title] (now in list_kb_sections + intent-taxonomy.md)
⏭️  Skipped (W):
  - [Subject] — [reason: holding reply / no answer yet / out of scope]
💾 Mirrored to Drive (S sections + snapshot):
  - [NN slug] … — or "full reconcile, N cards"
⚠️  For your attention:
  - [anything you want Peter to double-check]

Browse: https://platform.zynkr.ai/kb
Backup: https://drive.google.com/drive/folders/1LpymoVhy4YrxDBi81Sw6CRQQbZAiSLQ6
```

If a new section was created, confirm it's added to `references/intent-taxonomy.md`.

---

## Things to be careful about

- **Extraction, not generation.** Every fact in the KB must trace to something Peter wrote. Holding
  replies teach nothing — skip them.
- **One source of truth: the platform KB.** Drive is a *backup*, not a second KB. Never author a
  card in Drive, never cite the mirror as current, and never read a Drive edit back into the
  platform. If the two disagree, the platform is right and the mirror is stale.
- **A write that isn't mirrored is drift.** Step 6 runs in the same pass as Step 5, every time.
  Leaving it for later is how the 2026-07-15 → 08-26 gap happened (22 cards in one store only).
- **Never mirror unscoped.** `crm_kb_*` is shared/multi-tenant; always pass Peter's
  `--workspace`. An unscoped run leaks other tenants' cards into Peter's Drive.
- **Canonical numbers live once.** Pricing/policy/rates go in a `fact` card; qa cards **cite** it
  (`cites: ["<fact_id>"]`). Update the fact in one place rather than chasing numbers across cards.
- **Write the fact before the citing qa card** — cites only resolve against existing facts.
- **Supersede, don't pile up.** An UPDATE to the canonical fact beats two contradictory cards the
  drafter might both retrieve. `update_kb_article` auto-bumps `version`, so history is kept.
- **Never archive/delete without Peter asking.** Archiving (`status: "archived"`) is Peter's call.
- **PII never lands in the KB** — first name + company only.
- **Idempotency is the label.** A thread carrying `KMS-ingested` is done; never re-write it. A
  skipped thread stays un-labelled so it's reconsidered once truly answered.
- **zh-TW default**, bilingual keywords — match how Peter and his inquirers actually search.
- **Preview is not approval.** The MCP write tools dry-run without `confirm: true`; that's a safety
  net, not the gate. The gate is Peter's explicit approval in Step 4.