feat(partners): add partner-application-triage and partner-meeting-recap skills (#21819)
## What Adds two Twenty partner-pipeline skills to `twenty-partners/src/skills/`, plus a patch version bump. ### `twenty-partner-application-triage` Ranks the partner-application backlog by net-new value and surfaces a short chase-list of high-value applicants who haven't booked a call. Read-only against the live partners workspace. Ships `rank.py` as its scoring helper. ### `twenty-partner-meeting-recap` After partner calls, pulls Fireflies meetings, matches each to an existing Partner by attendee email/domain, writes a recap (transcript-first, Fireflies summary as fallback), and injects it as a Note linked to the partner via `NoteTarget`. Skips leads/discovery calls (no Partner match) and meetings whose content isn't ready yet. Optional `--prune` deletes the Fireflies recording once its recap is safely in the CRM (confirmed first). ## Version `twenty-partners` 0.5.4 → **0.5.5** (patch: additive skill docs, no app behaviour change). ## Notes - Both skills read credentials from `~/.twenty/credentials.env`; no secrets committed. - All GraphQL queries/mutations are the proven ones used against the live workspace.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-partners",
|
||||
"version": "0.5.4",
|
||||
"version": "0.5.5",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
---
|
||||
name: twenty-partner-application-triage
|
||||
description: Rank the partner-application backlog by net-new value and surface a short chase-list of high-value applicants who haven't booked a call. Use when the user wants to triage, rank, or prioritize partner applications, find which applicants are worth chasing, run the daily/weekly application review, or asks "who should I reach out to" / "which applications matter". Reads the live partners workspace; read-only.
|
||||
trigger: /twenty-partner-application-triage
|
||||
---
|
||||
|
||||
# twenty-partner-application-triage
|
||||
|
||||
Rank `APPLICATION`-stage partners by the value they would *add* — geographies and
|
||||
languages we don't yet cover, plus proof of real Twenty work — and hand back a short
|
||||
**chase-list**: the high-value applicants worth a personal nudge.
|
||||
|
||||
**The door stays open for everyone.** This skill does not reject or filter anyone out of
|
||||
the pipeline. Booking a call is the motivation filter; this just makes sure the good
|
||||
applicants who *didn't* book bubble up so they don't rot. The point is a few high-confidence
|
||||
partners, not maximum coverage.
|
||||
|
||||
Read-only. It never mutates a record.
|
||||
|
||||
---
|
||||
|
||||
## Credentials
|
||||
|
||||
Needs `~/.twenty/credentials.env` (same file the other partner skills use):
|
||||
|
||||
```env
|
||||
TWENTY_PARTNERS_API_URL=https://partners.twenty.com
|
||||
TWENTY_PARTNERS_API_KEY=<your key>
|
||||
```
|
||||
|
||||
The key lives in `packages/twenty-apps/internal/twenty-partners/.env.prod` (gitignored) —
|
||||
copy it to `~/.twenty/credentials.env` on first setup.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Run the ranker
|
||||
|
||||
```bash
|
||||
python3 "$(dirname "$0")/rank.py" # or: python3 rank.py from the skill dir
|
||||
```
|
||||
|
||||
`rank.py` is the deterministic core. It pulls every partner, computes each applicant's
|
||||
net-new geo / language / scope / skills vs the **VALIDATED** baseline, detects a "real
|
||||
Twenty work" proof signal in the notes, scores, and prints ranked JSON. It does not call an
|
||||
LLM — the judgment lives in you (Phase 1).
|
||||
|
||||
Each ranked entry: `name, score, tier (A/B/C), new_geo, new_lang, new_scope, new_skills,
|
||||
proof{workspace_url|customers|migration}, team, contact_name, email, linkedin, website,
|
||||
notes`.
|
||||
|
||||
Scoring (in `rank.py`, tune there if it drifts): geo +3 each, language +3 each, scope +1,
|
||||
skills +1 capped at 3 (so generic dev shops that spray skill lists can't dominate), proof
|
||||
+6. Any proof signal ⇒ at least tier A.
|
||||
|
||||
**Booking state:** if the JSON has `booking_state_wired: false`, the `callBookedAt` field
|
||||
isn't on the model yet, so the ranker scores **all** applications. Say so in the output.
|
||||
Once `callBookedAt` exists, the ranker auto-narrows to the un-booked (the true chase set) —
|
||||
no skill change needed.
|
||||
|
||||
If the run prints a missing-credentials error, stop and tell the user exactly which key to
|
||||
add and where.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Judgment pass (this is the point)
|
||||
|
||||
The score surfaces; you decide. Read the `notes` of the **top ~15** plus anything tier-B/C
|
||||
with a non-trivial note, and adjust:
|
||||
|
||||
- **Rescue the motivated-but-unobvious.** Someone whose checkboxes are thin but whose notes
|
||||
show real intent, a live Twenty instance, named customers, or a thoughtful pitch is a
|
||||
chase even at a low score. This is the whole reason a human/LLM reads the notes — the
|
||||
applicants who do things we don't see in the form are exactly who we don't want to lose.
|
||||
- **Sanity-check volume inflation.** A high score driven by 6 net-new *languages* from one
|
||||
solo, or a long skills list, may be aspirational. Confirm it against the notes before
|
||||
ranking it top. Real net-new geography with proof beats a long list every time.
|
||||
- **Demote noise.** Empty notes, agency spam, or "Tally submission: <id>" with nothing else
|
||||
is tier C regardless of score. Don't chase them.
|
||||
- **Note proof quality.** `workspace_url` + `customers` together (a live workspace with named
|
||||
clients) is the strongest signal — stronger than the raw score. Call it out.
|
||||
|
||||
Don't invent facts. If a note is ambiguous, say so rather than upgrading on a guess.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Output the chase-list
|
||||
|
||||
A tight digest, grouped by tier, A first. Lead with the count and the booking-state caveat.
|
||||
|
||||
```
|
||||
# Partner application triage — N applications ranked (booking state: not wired / un-booked only)
|
||||
|
||||
## Chase now (Tier A — fills a gap AND/OR proven)
|
||||
- **<name>** (<team>) — +<geo>/<lang>; proof: <workspace+customers/…>
|
||||
why: <one line, drawn from notes>
|
||||
reach: <email> · <linkedin>
|
||||
|
||||
## Worth a look (Tier B)
|
||||
- <name> — <one line>; reach: <email>
|
||||
|
||||
## Skip for now (Tier C) — <count>, not listed (empty/spam/no gap)
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Tier A is the actual worklist. Keep it short — if it's 18 long, the proof-backed gap-fillers
|
||||
go first and the volume-only ones go last.
|
||||
- Always give a reach handle (email from `contact_name`/`email`, else linkedin, else website).
|
||||
If none, say "no contact on record" — that itself is a data-quality flag.
|
||||
- Be honest. If only a handful are genuinely worth chasing, say so; don't pad the A-tier.
|
||||
|
||||
---
|
||||
|
||||
## What this is not
|
||||
|
||||
- Not a gate. It never moves anyone to `REJECTED` or out of the funnel.
|
||||
- Not a writer. It never edits a record. Surfacing only.
|
||||
- Not the production cron. This is the **dev surface** for the ranking. Once the chase-list
|
||||
is trustworthy, the deterministic core (`rank.py`) is what gets ported to a daily
|
||||
logic-function cron in the partner app that writes `ranking` + a tier onto each un-booked
|
||||
record so the workspace view sorts itself. The LLM judgment pass stays here, for the runs
|
||||
where you want a human in the loop. Build skill → trust it → port the cheap part. Don't
|
||||
build both.
|
||||
|
||||
## Self-check
|
||||
|
||||
`python3 rank.py --selftest` asserts the scoring orders a gap-filler-with-proof above a
|
||||
skill-sprayer above an empty record, and that skill volume stays capped. Run it after any
|
||||
edit to the weights or signal regexes.
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic ranker for the twenty-partner-application-triage skill.
|
||||
|
||||
Ranks APPLICATION-stage partners by NET-NEW value vs the current VALIDATED set:
|
||||
geography and language we don't yet cover (weighted high), plus a "real Twenty
|
||||
work" proof signal read from the application notes. Skill volume is deliberately
|
||||
capped so generic dev shops that spray skill lists don't dominate.
|
||||
|
||||
Reads creds from ~/.twenty/credentials.env. Emits ranked JSON to stdout.
|
||||
Run `rank.py --selftest` to verify scoring without hitting the API.
|
||||
|
||||
# ponytail: fixed weights + tier thresholds. Tune the WEIGHTS/THRESHOLDS dicts
|
||||
# below if the ranking drifts; everything else is mechanical.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
WEIGHTS = {"geo": 3, "lang": 3, "scope": 1, "skill": 1, "skill_cap": 3, "proof": 6}
|
||||
THRESHOLDS = {"A": 12, "B": 5} # >=A => A; >=B => B; else C. Any proof => at least A.
|
||||
|
||||
# "real Twenty work" signals in the free-text notes (the high-confidence axis).
|
||||
PROOF_WORKSPACE = re.compile(r"https?://[^\s]*(twenty|crm)[^\s]*", re.I)
|
||||
PROOF_CUSTOMERS = re.compile(r"(customers?\s+onboarded|real implementation|clients?\s+(moving|migrat)|delivered \d|named customer)", re.I)
|
||||
PROOF_MIGRATION = re.compile(r"(switch\w*|migrat\w*|moved|replac\w*|our crm|own crm|we use twenty|dogfood|managed service)", re.I)
|
||||
|
||||
CRED_PATH = os.path.expanduser("~/.twenty/credentials.env")
|
||||
|
||||
|
||||
def load_creds():
|
||||
if not os.path.exists(CRED_PATH):
|
||||
sys.exit(f"Missing {CRED_PATH}. Copy the partners key from the app's "
|
||||
".env.prod into it (TWENTY_PARTNERS_API_URL / _API_KEY).")
|
||||
env = {}
|
||||
with open(CRED_PATH) as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
env[k.strip()] = v.strip()
|
||||
url = env.get("TWENTY_PARTNERS_API_URL")
|
||||
key = env.get("TWENTY_PARTNERS_API_KEY")
|
||||
if not url or not key:
|
||||
sys.exit("credentials.env is missing TWENTY_PARTNERS_API_URL or TWENTY_PARTNERS_API_KEY.")
|
||||
return url.rstrip("/"), key
|
||||
|
||||
|
||||
def fetch_all_partners(url, key):
|
||||
recs, after = [], None
|
||||
while True:
|
||||
path = f"{url}/rest/partners?limit=60&depth=1" + (f"&starting_after={after}" if after else "")
|
||||
req = urllib.request.Request(path, headers={"Authorization": f"Bearer {key}", "User-Agent": "Mozilla/5.0"})
|
||||
data = json.load(urllib.request.urlopen(req))
|
||||
page = data["data"]["partners"]
|
||||
recs += page
|
||||
info = data.get("pageInfo", {})
|
||||
if info.get("hasNextPage") and page:
|
||||
after = info["endCursor"]
|
||||
continue
|
||||
return recs
|
||||
|
||||
|
||||
def tok(value):
|
||||
"""Flatten any nested value into a set of lowercased non-empty string tokens."""
|
||||
out = set()
|
||||
if value is None:
|
||||
return out
|
||||
if isinstance(value, str):
|
||||
s = value.strip().lower()
|
||||
if s:
|
||||
out.add(s)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
out |= tok(item)
|
||||
elif isinstance(value, dict):
|
||||
for item in value.values():
|
||||
out |= tok(item)
|
||||
else:
|
||||
out.add(str(value).lower())
|
||||
return out
|
||||
|
||||
|
||||
def notes_str(rec):
|
||||
v = rec.get("applicationNotes")
|
||||
if isinstance(v, str):
|
||||
return v
|
||||
return "" if v is None else json.dumps(v)
|
||||
|
||||
|
||||
def contact(rec):
|
||||
persons = rec.get("persons") or []
|
||||
if not persons:
|
||||
return None, None, rec.get("linkedin")
|
||||
p = persons[0]
|
||||
name = p.get("name") or {}
|
||||
full = " ".join(x for x in [name.get("firstName"), name.get("lastName")] if x) or None
|
||||
email = (p.get("emails") or {}).get("primaryEmail")
|
||||
linkedin = p.get("linkedinLink") or rec.get("linkedin")
|
||||
return full, email, linkedin
|
||||
|
||||
|
||||
def baseline(validated):
|
||||
geo, lang, scope, skill = set(), set(), set(), set()
|
||||
for r in validated:
|
||||
geo |= tok(r.get("region")) | tok(r.get("country"))
|
||||
lang |= tok(r.get("languagesSpoken"))
|
||||
scope |= tok(r.get("partnerScope"))
|
||||
skill |= tok(r.get("skills"))
|
||||
return {"geo": geo, "lang": lang, "scope": scope, "skill": skill}
|
||||
|
||||
|
||||
def score_one(rec, base):
|
||||
new_geo = sorted((tok(rec.get("region")) | tok(rec.get("country"))) - base["geo"])
|
||||
new_lang = sorted(tok(rec.get("languagesSpoken")) - base["lang"])
|
||||
new_scope = sorted(tok(rec.get("partnerScope")) - base["scope"])
|
||||
new_skill = sorted(tok(rec.get("skills")) - base["skill"])
|
||||
notes = notes_str(rec)
|
||||
proof = {
|
||||
"workspace_url": bool(PROOF_WORKSPACE.search(notes)),
|
||||
"customers": bool(PROOF_CUSTOMERS.search(notes)),
|
||||
"migration": bool(PROOF_MIGRATION.search(notes)),
|
||||
}
|
||||
has_proof = any(proof.values())
|
||||
score = (WEIGHTS["geo"] * len(new_geo)
|
||||
+ WEIGHTS["lang"] * len(new_lang)
|
||||
+ WEIGHTS["scope"] * len(new_scope)
|
||||
+ WEIGHTS["skill"] * min(len(new_skill), WEIGHTS["skill_cap"])
|
||||
+ (WEIGHTS["proof"] if has_proof else 0))
|
||||
if has_proof or score >= THRESHOLDS["A"]:
|
||||
tier = "A"
|
||||
elif score >= THRESHOLDS["B"]:
|
||||
tier = "B"
|
||||
else:
|
||||
tier = "C"
|
||||
name, email, linkedin = contact(rec)
|
||||
return {
|
||||
"name": rec.get("name"),
|
||||
"score": score,
|
||||
"tier": tier,
|
||||
"new_geo": new_geo,
|
||||
"new_lang": new_lang,
|
||||
"new_scope": new_scope,
|
||||
"new_skills": new_skill,
|
||||
"proof": {k: v for k, v in proof.items() if v},
|
||||
"team": rec.get("typeOfTeam"),
|
||||
"contact_name": name,
|
||||
"email": email,
|
||||
"linkedin": linkedin if isinstance(linkedin, str) else (linkedin or {}).get("primaryLinkUrl") if isinstance(linkedin, dict) else None,
|
||||
"website": (rec.get("website") or {}).get("primaryLinkUrl") if isinstance(rec.get("website"), dict) else None,
|
||||
"notes": notes.strip()[:400],
|
||||
}
|
||||
|
||||
|
||||
def rank(recs):
|
||||
validated = [r for r in recs if r.get("validationStage") == "VALIDATED"]
|
||||
apps = [r for r in recs if r.get("validationStage") == "APPLICATION"]
|
||||
# Forward-compat: once callBookedAt exists, narrow to the un-booked (the chase set).
|
||||
has_booked_field = any("callBookedAt" in r for r in recs)
|
||||
if has_booked_field:
|
||||
apps = [r for r in apps if not r.get("callBookedAt")]
|
||||
base = baseline(validated)
|
||||
ranked = sorted((score_one(r, base) for r in apps), key=lambda d: -d["score"])
|
||||
return {
|
||||
"validated_count": len(validated),
|
||||
"application_count": len(apps),
|
||||
"booking_state_wired": has_booked_field,
|
||||
"coverage": {k: sorted(v) for k, v in base.items()},
|
||||
"ranked": ranked,
|
||||
}
|
||||
|
||||
|
||||
def selftest():
|
||||
base_recs = [{"validationStage": "VALIDATED", "country": ["france"],
|
||||
"languagesSpoken": ["french", "english"], "partnerScope": ["development"],
|
||||
"skills": ["react", "postgres"]}]
|
||||
apps = [
|
||||
{"validationStage": "APPLICATION", "name": "gap+proof", "country": ["germany"],
|
||||
"languagesSpoken": ["german"], "applicationNotes":
|
||||
"Live workspace https://crm.acme.de — customers onboarded: Foo GmbH"},
|
||||
{"validationStage": "APPLICATION", "name": "skill-sprayer",
|
||||
"skills": ["php", "vue", "kotlin", "swift", "laravel", "mongodb"]},
|
||||
{"validationStage": "APPLICATION", "name": "empty"},
|
||||
]
|
||||
out = rank(base_recs + apps)["ranked"]
|
||||
order = [r["name"] for r in out]
|
||||
assert order == ["gap+proof", "skill-sprayer", "empty"], order
|
||||
assert out[0]["tier"] == "A", out[0]
|
||||
assert out[1]["score"] == WEIGHTS["skill_cap"], out[1] # 6 new skills capped at 3
|
||||
assert out[2]["tier"] == "C", out[2]
|
||||
print("selftest ok:", order)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--selftest" in sys.argv:
|
||||
selftest()
|
||||
else:
|
||||
url, key = load_creds()
|
||||
print(json.dumps(rank(fetch_all_partners(url, key)), indent=2, ensure_ascii=False))
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
---
|
||||
name: twenty-partner-meeting-recap
|
||||
description: Pull recent Fireflies partner meetings, match each to an existing Partner record by attendee email/domain, write a recap (transcript-first, Fireflies summary as fallback), and inject it as a Note on the partner's profile. Use after a batch of partner calls when you want each partner's CRM record updated with what was said. Read-only for leads/discovery calls (they have no Partner match and are skipped).
|
||||
trigger: /twenty-partner-meeting-recap
|
||||
---
|
||||
|
||||
# twenty-partner-meeting-recap
|
||||
|
||||
After partner calls: pull the Fireflies meetings, figure out which partner each one is (by matching an attendee to an existing Partner record), summarize the call, and drop that summary as a Note on the partner's profile. Runs end to end with no per-note confirmation.
|
||||
|
||||
Sibling of `twenty-partner-match`. This one is about **existing partners** (recap their calls), not about matching a lead to partners.
|
||||
|
||||
Optional `--prune`: after recaps are safely in the CRM, delete the corresponding Fireflies recordings to free storage (confirmed first — see Phase 6).
|
||||
|
||||
---
|
||||
|
||||
## Credentials
|
||||
|
||||
Reads `~/.twenty/credentials.env`:
|
||||
|
||||
```env
|
||||
TWENTY_PARTNERS_API_URL=https://partners.twenty.com
|
||||
TWENTY_PARTNERS_API_KEY=<your key>
|
||||
FIREFLIES_API_KEY=<your key>
|
||||
```
|
||||
|
||||
All three are required. The partners key lives in `packages/twenty-apps/internal/twenty-partners/.env.prod` (gitignored); the Fireflies key is your personal API key. Stop cleanly and name the missing key if any is absent.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Prerequisites
|
||||
|
||||
Read `~/.twenty/credentials.env`. Verify `TWENTY_PARTNERS_API_URL`, `TWENTY_PARTNERS_API_KEY`, `FIREFLIES_API_KEY` are all present. If one is missing, stop and tell the user exactly which key to add and where.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Pull meetings
|
||||
|
||||
Default scope: **meetings from the last 2 days** (covers "yesterday"). The user can override per run — "last week", a date, or by pasting specific Fireflies URLs/IDs (the ID is the trailing `01K...` segment of `app.fireflies.ai/view/<slug>::<ID>`).
|
||||
|
||||
List recent transcripts, then keep only those inside the scope window (Fireflies `date` is epoch milliseconds). For each kept transcript, fetch its detail (attendees + summary + sentences). See **Reference queries** for the exact GraphQL. Add a `User-Agent` header to every Fireflies request — the API rejects the default urllib agent.
|
||||
|
||||
`transcripts(limit:)` is **capped at 50** by Fireflies — a higher value is a hard `invalid_arguments` 400, not a soft clamp. Use 50 and page if you ever need more.
|
||||
|
||||
If two transcripts share the same partner and day (Fireflies sometimes double-records), keep the one with more sentences.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Match each meeting to a Partner
|
||||
|
||||
Only meetings tied to an existing Partner get processed. Leads/discovery calls (no Partner match) are skipped and listed at the end.
|
||||
|
||||
1. Page through all partners once, pulling each partner's linked person emails and company domain (see Reference queries). Build two maps:
|
||||
- `email -> partner` from every `persons.edges.node.emails.primaryEmail`
|
||||
- `domain -> [partners]` from each partner's `company.domainName.primaryLinkUrl`
|
||||
2. For each meeting, take the attendee emails, drop anything `@twenty.com` and the host/organizer (that's the Twenty side). For each remaining attendee email:
|
||||
- **Exact email match** against `email -> partner` wins (strongest signal).
|
||||
- Else **domain match** against `domain -> [partners]`, skipping free providers (`gmail.com`, `outlook.com`, `hotmail.com`, `yahoo.com`, `icloud.com`, `proton.me`, etc.). If exactly one partner shares the domain, match it. If several do, **flag in the report and skip** rather than guess.
|
||||
3. The meeting title is a secondary hint only (`Partner intro between … and <name>` was the historical convention) — never the primary matcher.
|
||||
|
||||
A meeting with no Partner match is a lead/other call: skip it, record it under "skipped" with the reason.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Summarize (transcript first)
|
||||
|
||||
For each matched meeting, pick the better source. **Judge content quality first — never write a note from nothing or from noise:**
|
||||
|
||||
- A transcript is **usable** only if it has real content: roughly `>= 15` sentences **and** an average of `>= 4` words per sentence. A handful of one-word lines (`Platform.` `Opportunity.` `Background.`) is garbled ASR, not a transcript — treat it as unusable even though the array is non-empty.
|
||||
- **Usable transcript** → write the recap yourself from the transcript. This is the default whenever a usable transcript exists.
|
||||
- **No usable transcript, but a Fireflies summary exists** → fall back to `summary.overview` (try richer fields, then `short_summary`). Note the fallback in the source line.
|
||||
- **Neither** (no usable transcript AND empty summary) → the call is **still processing or unrecorded**. Skip it, record it under "skipped: content not ready", and move on. Today's calls often land here for a while after they end. Never inject an empty or placeholder note.
|
||||
|
||||
Write the note in English, structured (the format validated previously). No em dashes — use `:` or `,`.
|
||||
|
||||
```
|
||||
**TL;DR:** one-line verdict / state of the relationship.
|
||||
|
||||
**Profil:** team size, location, languages, structure.
|
||||
**Compétences Twenty:** deployment (cloud / self-host), data model, migrations, what they've actually shipped.
|
||||
**Contexte:** background, how they found Twenty, motivation, target clients, current partnerships.
|
||||
**Next steps:** concrete follow-ups (who owes what).
|
||||
**Flags:** risks, unknowns, ASR artifacts to double-check.
|
||||
|
||||
Source: Fireflies <transcript-id> (call <YYYY-MM-DD>, transcript|summary).
|
||||
```
|
||||
|
||||
The `Source: Fireflies <transcript-id>` line is load-bearing: it is the dedup key for re-runs. Always include the real transcript id.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Inject the Note (automatic)
|
||||
|
||||
For each matched meeting, before writing, check whether this meeting was already noted:
|
||||
|
||||
- Read the partner's existing notes (noteTargets filtered by `targetPartnerId`). Look for a note whose body contains `Fireflies <transcript-id>`.
|
||||
- **No such note** → create one: `createNote` with `bodyV2.markdown`, then `createNoteTarget` linking `noteId` to `targetPartnerId`. Title: `Partner call recap: <Partner name> (<YYYY-MM-DD>)`.
|
||||
- **Note already exists** → regenerate the recap, diff it against the existing body, and **append only net-new information** under a dated `**Update <YYYY-MM-DD>:**` block via `updateNote` (`bodyV2.markdown` = existing body + the new block). If nothing is new, leave it untouched.
|
||||
|
||||
No confirmation step — match, summarize, write. Then verify each write by reading the note back and confirming the partner link resolved.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Report
|
||||
|
||||
Print one table:
|
||||
|
||||
| Meeting (date · title) | Attendee matched | Partner | Action |
|
||||
|---|---|---|---|
|
||||
|
||||
`Action` is one of: `created`, `updated (appended)`, `unchanged`, `skipped: no partner match`, `skipped: ambiguous domain (N partners)`, `skipped: content not ready`. End with counts (`created / updated / unchanged / skipped`).
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Prune (`--prune`, opt-in, deletes Fireflies recordings)
|
||||
|
||||
Runs only when invoked with `--prune` (Fireflies storage fills up; recordings whose content is already safe in the CRM are dead weight). **Deletion is irreversible and on an external service — always confirm before deleting.**
|
||||
|
||||
A recording is **safe to prune** only when its recap note is confirmed written this run (`created` or `updated`) **or** already exists in the CRM with this transcript's `Fireflies <id>` in its body. Never prune a meeting that was skipped, has no note, or whose note you could not verify — losing the recording would lose the only copy.
|
||||
|
||||
1. Build the prune set from this run's safe meetings (plus, if asked to "free more", existing recap notes whose `Fireflies <id>` you can resolve to a still-present transcript).
|
||||
2. **Present the exact list** (partner, transcript id, date) and get explicit confirmation. Default to keeping the most recent unless told otherwise.
|
||||
3. Delete each confirmed transcript with `deleteTranscript(id:)`, then **verify** by re-listing and confirming the ids are absent. Report `deleted N/M` and how many transcripts remain.
|
||||
|
||||
Matching old notes back to transcripts: yesterday's notes embed only a date, not the id, so fall back to the meeting-title person name (`Partner intro between … and <name>` / `… - <name> x Rashad`) against the partner name in the note title. New notes written by this skill carry `Fireflies <id>` in the body, so the mapping is exact going forward.
|
||||
|
||||
---
|
||||
|
||||
## Reference queries
|
||||
|
||||
All partner calls go to `$TWENTY_PARTNERS_API_URL/graphql` with `Authorization: Bearer $TWENTY_PARTNERS_API_KEY`. All Fireflies calls go to `https://api.fireflies.ai/graphql` with `Authorization: Bearer $FIREFLIES_API_KEY` **and** a browser `User-Agent`. Helper:
|
||||
|
||||
```python
|
||||
import os, json, urllib.request
|
||||
creds = {}
|
||||
for line in open(os.path.expanduser("~/.twenty/credentials.env")):
|
||||
line = line.strip()
|
||||
if line and "=" in line and not line.startswith("#"):
|
||||
k, v = line.split("=", 1); creds[k] = v.strip()
|
||||
|
||||
def gql(url, key, query, variables=None):
|
||||
body = json.dumps({"query": query, "variables": variables or {}}).encode()
|
||||
req = urllib.request.Request(url, data=body, headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + key,
|
||||
"User-Agent": "Mozilla/5.0"})
|
||||
return json.load(urllib.request.urlopen(req, timeout=90))
|
||||
```
|
||||
|
||||
**Fireflies — list recent transcripts** (`date` is epoch ms; `limit` max 50):
|
||||
```graphql
|
||||
query{ transcripts(limit:50){ id title date duration participants meeting_attendees{ displayName email } } }
|
||||
```
|
||||
|
||||
**Fireflies — delete a recording (`--prune` only):**
|
||||
```graphql
|
||||
mutation($id:String!){ deleteTranscript(id:$id){ id title } }
|
||||
```
|
||||
|
||||
**Fireflies — one transcript's detail:**
|
||||
```graphql
|
||||
query($id:String!){ transcript(id:$id){
|
||||
title date duration participants host_email organizer_email
|
||||
meeting_attendees{ displayName email }
|
||||
summary{ overview short_summary keywords }
|
||||
sentences{ speaker_name text } } }
|
||||
```
|
||||
|
||||
**Partners — page through all with person emails + company domain:**
|
||||
```graphql
|
||||
query($a:String){ partners(after:$a){
|
||||
pageInfo{ hasNextPage endCursor }
|
||||
edges{ node{
|
||||
id name slug validationStage
|
||||
persons{ edges{ node{ name{ firstName lastName } emails{ primaryEmail } } } }
|
||||
company{ name domainName{ primaryLinkUrl } } } } } }
|
||||
```
|
||||
Paginate until `pageInfo.hasNextPage` is false, passing `endCursor` as `$a`.
|
||||
|
||||
**Partner — existing notes (dedup check):**
|
||||
```graphql
|
||||
query($pid:UUID!){ noteTargets(filter:{ targetPartnerId:{ eq:$pid } }){
|
||||
edges{ node{ note{ id title bodyV2{ markdown } createdAt } } } } }
|
||||
```
|
||||
|
||||
**Write a note and link it to the partner:**
|
||||
```graphql
|
||||
mutation($d:NoteCreateInput!){ createNote(data:$d){ id title } }
|
||||
# variables: { "d": { "title": "...", "bodyV2": { "markdown": "..." } } }
|
||||
|
||||
mutation($d:NoteTargetCreateInput!){ createNoteTarget(data:$d){ id targetPartnerId } }
|
||||
# variables: { "d": { "noteId": "<note id>", "targetPartnerId": "<partner id>" } }
|
||||
```
|
||||
|
||||
**Append to an existing note (re-run path):**
|
||||
```graphql
|
||||
mutation($id:UUID!,$d:NoteUpdateInput!){ updateNote(id:$id,data:$d){ id } }
|
||||
# variables: { "id": "<note id>", "d": { "bodyV2": { "markdown": "<existing + new block>" } } }
|
||||
```
|
||||
|
||||
**Verify a write:**
|
||||
```graphql
|
||||
query($id:UUID!){ note(filter:{ id:{ eq:$id } }){
|
||||
id title noteTargets{ edges{ node{ targetPartnerId targetPartner{ name } } } } } }
|
||||
```
|
||||
Reference in New Issue
Block a user