From 34f2b95cc2da1eed13142afbea58f977b8ee266c Mon Sep 17 00:00:00 2001 From: tg-autopilot Date: Fri, 11 Sep 2026 11:17:46 +0545 Subject: [PATCH 1/2] feat: trust a resolved conversation as fully handled by support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A resolved conversation, first time or Nth time, no longer triggers classification/investigation on its own -- the resolved-conversation loop in crisp-classify.mjs now just records "session X seen resolved at time T" into the new state/resolved-seen.json, nothing more. Investigation still happens via three paths: - Manual !tg-autopilot investigate note (unchanged, always overrides). - A conversation reopening (going active again) after a prior resolve -- investigated using only messages after that recorded resolve time. If it gets resolved again before a run ever catches it in that in-between window, it's never investigated for that reopen at all: the resolved-conversation loop just records the newer resolve time on its next pass and there's nothing left to catch. Intentional, not a gap -- a second resolve is trusted the same as the first. - The existing stale-12h-and-never-resolved auto-escalation path, unchanged. Updates PHASE2-SETUP.md § 4c and the README's feature table to describe the new policy, and adds state/resolved-seen.json to crisp-triage.yml's committed state files. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/crisp-triage.yml | 2 +- .github/workflows/diagnose-message-shape.yml | 20 --- PHASE2-SETUP.md | 15 +- README.md | 2 +- scripts/crisp-classify.mjs | 153 +++++++++++-------- scripts/diagnose-message-shape.mjs | 14 -- 6 files changed, 98 insertions(+), 108 deletions(-) delete mode 100644 .github/workflows/diagnose-message-shape.yml delete mode 100644 scripts/diagnose-message-shape.mjs diff --git a/.github/workflows/crisp-triage.yml b/.github/workflows/crisp-triage.yml index 763bcbf..75bb250 100644 --- a/.github/workflows/crisp-triage.yml +++ b/.github/workflows/crisp-triage.yml @@ -91,7 +91,7 @@ jobs: set +e git config user.name "tg-autopilot" git config user.email "tg-autopilot@users.noreply.github.com" - git add state/cursor.json state/active-notified.json state/escalated.json state/investigated.json + git add state/cursor.json state/active-notified.json state/escalated.json state/investigated.json state/resolved-seen.json git diff --cached --quiet || git commit -m "Advance Crisp triage state [skip ci]" for attempt in 1 2 3 4 5; do git push && exit 0 diff --git a/.github/workflows/diagnose-message-shape.yml b/.github/workflows/diagnose-message-shape.yml deleted file mode 100644 index db118c2..0000000 --- a/.github/workflows/diagnose-message-shape.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Diagnose message shape - -on: - workflow_dispatch: - -jobs: - diagnose: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: "20" - - run: npm install - - name: Diagnose - env: - CRISP_USER_REGISTRATION_IDENTIFIER: ${{ secrets.CRISP_USER_REGISTRATION_IDENTIFIER }} - CRISP_USER_REGISTRATION_KEY: ${{ secrets.CRISP_USER_REGISTRATION_KEY }} - CRISP_USER_REGISTRATION_WEBSITE_ID: ${{ vars.CRISP_USER_REGISTRATION_WEBSITE_ID }} - run: node scripts/diagnose-message-shape.mjs diff --git a/PHASE2-SETUP.md b/PHASE2-SETUP.md index 35a1e80..83a3e6f 100644 --- a/PHASE2-SETUP.md +++ b/PHASE2-SETUP.md @@ -60,20 +60,21 @@ Phase 1's PAT has **Contents: Read-only**. Stage 1 needs to commit the advanced - `themegrill`'s org default repo permission happens to be **write** for all members, so once `tg-autopilot` is a member there, it already has write access to every repo — no per-repo collaborator setup was needed, just the org invite and this token. - The workflow picks whichever token matches a conversation's target repo's org: `startsWith(matrix.repo, 'themegrill/') && secrets.BOT_TOKEN_THEMEGRILL || secrets.BOT_TOKEN`. Onboarding a third org later means the same pattern: a new PAT, a new secret, one more branch in that expression (or, once there are more than two, worth switching to a lookup table instead). -## 4c. Escalating a still-open conversation, without waiting for it to resolve +## 4c. What actually triggers a full investigation -Normally, full investigation only ever runs on a *resolved* conversation — see the reasoning in `prompts/crisp-triage-agent.md`. Two exceptions, both handled in `crisp-classify.mjs`, not the lightweight active-dedupe script: +**Policy: a resolved conversation is trusted as fully handled by support.** Resolving it — first time or the tenth time — never triggers investigation on its own; the resolved-conversation loop in `crisp-classify.mjs` only records "session X seen resolved at time T" into `state/resolved-seen.json`, nothing more. Three things can still trigger a full investigation: -- **Manual**: a support agent adds a private note containing `!tg-autopilot investigate`. Skips the cheap classifier entirely — a human already made the call — and goes straight to full investigation. Works any number of times; each *new* note re-triggers it (tracked by counting matching notes per conversation, not by trying to identify "which" note, since two notes can have identical text). Detection has two layers: the cheap path only fetches full messages for conversations already touched since `cursor.last_checked` (adding a note is itself an update); separately, `searchConversationsForManualTrigger()` searches Crisp directly for the trigger phrase and appends anything found there too, regardless of `fetchActiveConversations`' page cap. The second layer exists because the first one alone isn't reliable on a high-volume account — confirmed for real on `THEMEGRILL`: a manually-noted conversation didn't rank in the top 200 most-recently-updated active conversations because 200+ *other* conversations were touched in the same window, so it never got its message history fetched at all. A manual trigger is an explicit human action and shouldn't ever silently fail to reach the pipeline just because the account is busy. -- **Automatic**: a conversation open longer than **12 hours** (measured from `active.last`, not `created_at` -- a conversation that resolved and later reopened after a long gap shouldn't look artificially old) gets checked by the same cheap classifier used for resolved conversations. If it agrees this looks like a real bug/feature, it's escalated the same way. This fires **at most once per conversation** automatically, and never for a conversation older than **30 days** (`AUTO_ESCALATE_MAX_HOURS` in `crisp-classify.mjs`) -- a backlog that's sat untouched that long is treated as intentionally left open, not a scan miss. If something changes later and it genuinely needs another look, that's what the manual note is for. +- **Manual**: a support agent adds a private note containing `!tg-autopilot investigate`. Skips the cheap classifier entirely — a human already made the call — and goes straight to full investigation, on the *full* transcript regardless of any of the below. Works any number of times; each *new* note re-triggers it (tracked by counting matching notes per conversation, not by trying to identify "which" note, since two notes can have identical text). Detection has two layers: the cheap path only fetches full messages for conversations already touched since `cursor.last_checked` (adding a note is itself an update); separately, `searchConversationsForManualTrigger()` searches Crisp directly for the trigger phrase and appends anything found there too, regardless of `fetchActiveConversations`' page cap. The second layer exists because the first one alone isn't reliable on a high-volume account — confirmed for real on `THEMEGRILL`: a manually-noted conversation didn't rank in the top 200 most-recently-updated active conversations because 200+ *other* conversations were touched in the same window, so it never got its message history fetched at all. A manual trigger is an explicit human action and shouldn't ever silently fail to reach the pipeline just because the account is busy. +- **Reopen after resolve**: a conversation `state/resolved-seen.json` already has a record for shows up in the *active* list again (the customer replied after it was marked resolved). Investigated using only the messages timestamped after that recorded resolve time — whatever was there before is assumed already covered by support's original resolve. If it gets resolved again before a `crisp-triage` run ever catches it in this in-between "active again" state, it's never investigated for that reopen at all — the resolved-conversation loop just records the newer resolve time on its next pass, and there's nothing left to catch. This is intentional, not a bug: by the same policy, a second resolve is trusted as handled too. +- **Automatic, stale-and-never-resolved**: a conversation open longer than **12 hours** (measured from `active.last`, not `created_at`) that has *never* been resolved at all gets checked by the same cheap classifier. If it agrees this looks like a real bug/feature, it's escalated the same way. This fires **at most once per conversation** automatically, and never for a conversation older than **30 days** (`AUTO_ESCALATE_MAX_HOURS` in `crisp-classify.mjs`) -- a backlog that's sat untouched that long is treated as intentionally left open, not a scan miss. This path is unrelated to the reopen path above — it exists for tickets that were *never* marked resolved, which the "trust the resolve" policy says nothing about. **Onboarding a new account: run `Seed escalated state for a new Crisp account` (`.github/workflows/seed-escalated.yml`, `workflow_dispatch`, input = the account key) once, before that account's first scheduled `crisp-triage` run.** `escalated.json` has no history for a brand-new account, so every one of its currently-active conversations already past 12 hours old looks auto-escalation-eligible on day one -- confirmed for real onboarding User Registration: ~83% of its ~400-conversation active backlog auto-escalated within the first few minutes of a single run. The seed workflow marks the current backlog as already-escalated so none of it fires; only conversations that go stale *after* seeding will auto-escalate from then on. It doesn't touch the manual-note path -- `!tg-autopilot investigate` still works on any of those backlog conversations if someone wants one looked at anyway. -Both paths write to `state/escalated.json` (also committed) so neither one repeats itself needlessly. +All three paths write to `state/escalated.json` and/or `state/resolved-seen.json` (both committed) so none of them repeats itself needlessly. -**Separately**, `state/investigated.json` (also committed) tracks every session_id that has ever completed a full Stage 2 investigation, from *any* path (resolved, auto-escalation, or manual note). The resolved-conversation loop and the auto-escalation branch both skip a session already in this set — otherwise a conversation that resolves, gets reopened by the customer, and resolves again looks like a brand-new resolved conversation every cycle, with no memory that it was already fully investigated (confirmed for real: one conversation was reinvestigated 6 times over 3 days, posting a fresh "not a real defect" note each time — `crisp-post-note.mjs`'s dedup only catches a repeated note that references the *same GitHub issue URL*, so a no-issue conclusion is never deduped). The manual-note path deliberately does **not** check this set — a human explicitly asking to (re-)investigate should always go through. +**Separately**, `state/investigated.json` (also committed) tracks every session_id that has ever completed a full Stage 2 investigation, from *any* path. The reopen and stale-auto-escalation branches both skip a session already in this set for the *same* unresolved content — a conversation reopening with a genuinely new, unrelated problem after already being investigated once still gets picked up, since the reopen path is driven by `resolved-seen.json`'s timestamp, not this set. The manual-note path deliberately does **not** check this set — a human explicitly asking to (re-)investigate should always go through. -This does not retroactively clean up notes already posted before the fix, and it does mean a conversation that reopens with a genuinely new, unrelated problem after already being investigated once won't get a second automatic look — only a fresh manual `!tg-autopilot investigate` note reaches it at that point. +**Rollout note**: `state/resolved-seen.json` starts empty when this policy first ships. A conversation that was resolved *before* that point and reopens shortly after has no prior "seen resolved" record yet, so that first reopen looks like a first-time resolve and gets skipped rather than recognized as a reopen — it self-corrects from its next resolve onward. Accepted as a one-time gap rather than backfilling the whole history. ## 4d. Investigate-job concurrency and OpenAI rate limits diff --git a/README.md b/README.md index b0db0b5..825b1c3 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ We use a machine user instead of a GitHub App because it keeps one identity acro |---|---|---| | **PR build-zip comment** — builds a plugin/theme zip on every ready-for-review PR, uploads it, and posts/updates one comment with a direct download link | `.github/workflows/pr-build-zip.yml` + `.caller.yml` | [SETUP.md](SETUP.md) | | **PR review automation** — requests Copilot as a reviewer when a PR opens, and lets team members trigger a Copilot code review by commenting a trigger phrase | `.github/workflows/copilot-review-on-comment.yml` + `.caller.yml` | inline comments in the workflow file | -| **Crisp → AI → GitHub issue** — hourly pipeline that reads resolved (and some still-open) Crisp support conversations, classifies whether they describe a real bug/feature, and either files a GitHub issue, comments on an existing one, or leaves a note back in the Crisp conversation | `.github/workflows/crisp-triage.yml`, `scripts/`, `prompts/`, `config/`, `state/` | [PHASE2-SETUP.md](PHASE2-SETUP.md) | +| **Crisp → AI → GitHub issue** — scheduled pipeline over Crisp support conversations. A resolved conversation is trusted as fully handled by support and is never classified on its own; investigation only happens on a manual `!tg-autopilot investigate` note, a conversation reopening after being resolved (just the new part), or a stale conversation that was never resolved at all. See § 4c for the full policy | `.github/workflows/crisp-triage.yml`, `scripts/`, `prompts/`, `config/`, `state/` | [PHASE2-SETUP.md § 4c](PHASE2-SETUP.md#4c-what-actually-triggers-a-full-investigation) | ## Maintainer skills diff --git a/scripts/crisp-classify.mjs b/scripts/crisp-classify.mjs index 3ea0487..20cec5e 100644 --- a/scripts/crisp-classify.mjs +++ b/scripts/crisp-classify.mjs @@ -1,18 +1,22 @@ #!/usr/bin/env node -// Stage 1 of the Crisp -> AI -> GitHub issue pipeline: fetches conversations -// resolved since the last run, maps each to a repo, and classifies each with -// one cheap, tool-less model call. Most conversations aren't actionable and -// stop here. +// Stage 1 of the Crisp -> AI -> GitHub issue pipeline. // -// Also escalates a still-open conversation to full investigation without -// waiting for it to resolve -- see PHASE2-SETUP.md § 4c for the full policy -// (manual "!tg-autopilot investigate" note, or auto-escalation after -// AUTO_ESCALATE_HOURS with an upper bound so an old backlog isn't blindly -// swept in). +// Policy: a resolved conversation is trusted as fully handled by support -- +// resolving it (first time or Nth time) never triggers investigation on its +// own. The only way a previously-resolved conversation gets investigated is +// if it reopens (goes active again) before its next resolve; the resolved- +// conversation loop below only records "seen resolved at T", and the +// active-conversation loop investigates a reopen using messages after T. If +// it gets resolved again before we ever see it active in between, it's +// never investigated at all -- that falls out naturally from this loop +// always just re-recording the (later) resolve time, no special-casing +// needed. See PHASE2-SETUP.md § 4c for the auto-escalation policy for +// conversations that were never resolved in the first place, and the manual +// "!tg-autopilot investigate" note, which always overrides all of this. // -// Writes matrix.json (Stage 2's input), state/cursor.json, and -// state/escalated.json (per-session bookkeeping so neither escalation path -// repeats itself). +// Writes matrix.json (Stage 2's input), state/cursor.json, +// state/escalated.json, and state/resolved-seen.json (per-session +// bookkeeping so none of the above repeats itself). // // Provider: OpenAI, swappable freely. Verify CLASSIFY_MODEL against your // account before relying on it. @@ -31,6 +35,13 @@ const { GITHUB_STEP_SUMMARY } = process.env; const AUTO_ESCALATE_HOURS = 12; const AUTO_ESCALATE_MAX_HOURS = 24 * 30; // past this, only a manual note escalates it +function transcriptFrom(messages) { + return messages + .filter((m) => m.type === "text") + .map((m) => `${m.from === "user" ? "Customer" : "Agent"}: ${m.content}`) + .join("\n"); +} + async function main() { const runStartedAt = new Date().toISOString(); const cursor = JSON.parse(await readFile("state/cursor.json", "utf8")); @@ -48,14 +59,17 @@ async function main() { const investigated = new Set( JSON.parse(await readFile("state/investigated.json", "utf8").catch(() => "[]")) ); + // { [session_id]: } -- + // the only signal that lets the active loop below recognize a reopen. + const resolvedSeen = JSON.parse(await readFile("state/resolved-seen.json", "utf8").catch(() => "{}")); const matrix = []; const skippedUnmapped = []; let alreadyHandled = 0; - let alreadyInvestigated = 0; let totalFetched = 0; let manualEscalations = 0; let autoEscalations = 0; + let reopenEscalations = 0; // Each Crisp account (different logins) is fetched/classified independently; // one not yet credentialed is skipped with a warning, not a crash. @@ -68,7 +82,8 @@ async function main() { continue; } - // ---- Resolved conversations: the full pipeline, as before ---- + // ---- Resolved conversations: record as seen, never investigate here ---- + // except for an explicit manual note, which always overrides the policy. const conversations = await fetchResolvedConversationsSince(creds, cursor.last_checked); totalFetched += conversations.length; console.log(`[${accountKey}] fetched ${conversations.length} resolved conversations since ${cursor.last_checked}`); @@ -82,61 +97,42 @@ async function main() { // Fetch raw messages (not just the transcript) so a manual note can be // counted below without a second Crisp call. const messages = await fetchRawMessages(creds, conversation.session_id); - const transcript = messages - .filter((m) => m.type === "text") - .map((m) => `${m.from === "user" ? "Customer" : "Agent"}: ${m.content}`) - .join("\n"); - if (!transcript.trim()) continue; + const transcript = transcriptFrom(messages); - // A manual note always overrides the cheap classifier and the - // investigated guard. Needed here too (not just in the active loop - // below) since a conversation that resolved before its note was ever - // checked would otherwise silently skip the human's explicit request. - const record = escalated[conversation.session_id] ?? { autoEscalated: false, manualNoteCount: 0 }; - const manualNoteCount = countManualTriggerNotes(messages); - const hasNewManualNote = manualNoteCount > record.manualNoteCount; + if (transcript.trim()) { + const record = escalated[conversation.session_id] ?? { autoEscalated: false, manualNoteCount: 0 }; + const manualNoteCount = countManualTriggerNotes(messages); + const hasNewManualNote = manualNoteCount > record.manualNoteCount; - if (hasNewManualNote) { - const result = await classifyAndRoute(accountConfig, conversation, transcript, { skipClassifier: true }); - record.manualNoteCount = manualNoteCount; - escalated[conversation.session_id] = record; - if (result.repo) { - matrix.push({ session_id: conversation.session_id, repo: result.repo, kind: result.kind, account: accountKey }); - investigated.add(conversation.session_id); - manualEscalations++; - console.log(`[${accountKey}] ${conversation.session_id}: manual "!tg-autopilot investigate" note -> escalated to ${result.repo}`); - } else { - skippedUnmapped.push({ account: accountKey, session_id: conversation.session_id, inboxKey: result.unmappedKey }); + if (hasNewManualNote) { + const result = await classifyAndRoute(accountConfig, conversation, transcript, { skipClassifier: true }); + record.manualNoteCount = manualNoteCount; + escalated[conversation.session_id] = record; + if (result.repo) { + matrix.push({ session_id: conversation.session_id, repo: result.repo, kind: result.kind, account: accountKey }); + investigated.add(conversation.session_id); + manualEscalations++; + console.log(`[${accountKey}] ${conversation.session_id}: manual "!tg-autopilot investigate" note -> escalated to ${result.repo}`); + } else { + skippedUnmapped.push({ account: accountKey, session_id: conversation.session_id, inboxKey: result.unmappedKey }); + } } - continue; } - if (investigated.has(conversation.session_id)) { - alreadyInvestigated++; - continue; - } - - const result = await classifyAndRoute(accountConfig, conversation, transcript); - if (!result.repo) { - skippedUnmapped.push({ account: accountKey, session_id: conversation.session_id, inboxKey: result.unmappedKey }); - continue; - } - - console.log(`[${accountKey}] ${conversation.session_id}: actionable=${result.actionable} kind=${result.kind} repo=${result.repo}`); - - if (result.actionable && result.kind !== "none") { - matrix.push({ session_id: conversation.session_id, repo: result.repo, kind: result.kind, account: accountKey }); - investigated.add(conversation.session_id); - } + // Record every resolved conversation as seen, regardless of the + // manual-note branch above -- a later reopen is measured from + // whichever resolve happened most recently. + resolvedSeen[conversation.session_id] = conversation.updated_at ?? Date.now(); } - // ---- Active conversations: manual-note and time-based escalation ---- + // ---- Active conversations: manual note, reopen, and time-based escalation ---- // Independent of crisp-dedupe-active.mjs, which skips anything that // ends up in matrix.json this run. // // Only fetch messages for conversations touched since cursor.last_checked // (adding a note is itself an update) -- cheaper than fetching every - // active conversation's history. Staleness needs no message fetch at all. + // active conversation's history. Staleness/reopen detection need no + // message fetch at all. const lastCheckedMs = new Date(cursor.last_checked).getTime(); const activeConversations = await fetchActiveConversations(creds); activeConversations.forEach((conversation) => { @@ -159,6 +155,7 @@ async function main() { for (const conversation of activeConversations) { const record = escalated[conversation.session_id] ?? { autoEscalated: false, manualNoteCount: 0 }; + const previousResolveAt = resolvedSeen[conversation.session_id]; // active.last, not created_at: a reopened thread gets a fresh grace // period rather than reading as ancient. Pure metadata, no message fetch. @@ -170,18 +167,27 @@ async function main() { staleHours >= AUTO_ESCALATE_HOURS && staleHours <= AUTO_ESCALATE_MAX_HOURS; - if (!conversation._checkManualNote && !eligibleForAutoEscalate) continue; + // Previously seen resolved, now active again -- a reopen. Checked + // regardless of staleness/manual note: if it resolves again before we + // ever catch it here, the resolved-conversation loop above just + // records the later resolve time and this never fires for that gap. + const isReopen = previousResolveAt !== undefined; + + if (!conversation._checkManualNote && !eligibleForAutoEscalate && !isReopen) continue; const messages = await fetchRawMessages(creds, conversation.session_id); const manualNoteCount = conversation._checkManualNote ? countManualTriggerNotes(messages) : record.manualNoteCount; const hasNewManualNote = manualNoteCount > record.manualNoteCount; - if (!hasNewManualNote && !eligibleForAutoEscalate) continue; + if (!hasNewManualNote && !eligibleForAutoEscalate && !isReopen) continue; - const transcript = messages - .filter((m) => m.type === "text") - .map((m) => `${m.from === "user" ? "Customer" : "Agent"}: ${m.content}`) - .join("\n"); + // A reopen only looks at what's new since the previous resolve (a + // manual note overrides this and always gets the full transcript, same + // as everywhere else this pipeline honors that note). + const relevantMessages = isReopen && !hasNewManualNote + ? messages.filter((m) => (m.timestamp ?? 0) > previousResolveAt) + : messages; + const transcript = transcriptFrom(relevantMessages); if (!transcript.trim()) continue; if (hasNewManualNote) { @@ -196,6 +202,19 @@ async function main() { } else { skippedUnmapped.push({ account: accountKey, session_id: conversation.session_id, inboxKey: result.unmappedKey }); } + } else if (isReopen) { + const result = await classifyAndRoute(accountConfig, conversation, transcript); + if (result.repo && result.actionable && result.kind !== "none") { + matrix.push({ session_id: conversation.session_id, repo: result.repo, kind: result.kind, account: accountKey }); + investigated.add(conversation.session_id); + reopenEscalations++; + console.log(`[${accountKey}] ${conversation.session_id}: reopened after resolve, classifier agrees -> escalated to ${result.repo}`); + } + // Advance the marker to the newest message we just looked at, so a + // conversation that's still open (not yet resolved again) doesn't + // get the same content re-classified on the next run. + const newestMs = messages.reduce((max, m) => Math.max(max, m.timestamp ?? 0), previousResolveAt); + resolvedSeen[conversation.session_id] = newestMs; } else if (eligibleForAutoEscalate) { const result = await classifyAndRoute(accountConfig, conversation, transcript); record.autoEscalated = true; // fires at most once, whether actionable or not @@ -226,13 +245,17 @@ async function main() { await writeFile("state/escalated.json", JSON.stringify(escalated, null, 2) + "\n"); // Bound growth, same as active-notified.json. await writeFile("state/investigated.json", JSON.stringify([...investigated].slice(-2000))); + await writeFile( + "state/resolved-seen.json", + JSON.stringify(Object.fromEntries(Object.entries(resolvedSeen).slice(-5000)), null, 2) + "\n" + ); if (GITHUB_STEP_SUMMARY) { const lines = [ `### Crisp triage — Stage 1`, ``, - `Fetched: ${totalFetched} · Actionable: ${dedupedMatrix.length} · Unmapped (skipped): ${skippedUnmapped.length} · Already handled while active (skipped): ${alreadyHandled} · Already fully investigated before (skipped): ${alreadyInvestigated}${duplicatesRemoved ? ` · Duplicate session_id across loops (deduped): ${duplicatesRemoved}` : ""}`, - `Escalated from active: ${manualEscalations} manual, ${autoEscalations} auto (stale ${AUTO_ESCALATE_HOURS}h–${AUTO_ESCALATE_MAX_HOURS}h)`, + `Fetched (resolved, recorded as seen, not investigated): ${totalFetched} · Actionable: ${dedupedMatrix.length} · Unmapped (skipped): ${skippedUnmapped.length} · Already handled while active (skipped): ${alreadyHandled}${duplicatesRemoved ? ` · Duplicate session_id across loops (deduped): ${duplicatesRemoved}` : ""}`, + `Escalated from active: ${manualEscalations} manual, ${reopenEscalations} reopen-after-resolve, ${autoEscalations} stale-auto (${AUTO_ESCALATE_HOURS}h–${AUTO_ESCALATE_MAX_HOURS}h)`, ]; if (skippedUnmapped.length) { lines.push(``, `Unmapped inbox keys / unidentified products seen (add these to config/inbox-to-repo.json if real):`); diff --git a/scripts/diagnose-message-shape.mjs b/scripts/diagnose-message-shape.mjs deleted file mode 100644 index 0da83d3..0000000 --- a/scripts/diagnose-message-shape.mjs +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env node -import { fetchRawMessages, credsForAccount, fetchResolvedConversationsSince } from "./crisp-client.mjs"; - -const creds = credsForAccount("USER_REGISTRATION"); -const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); -const conversations = await fetchResolvedConversationsSince(creds, since); -console.log(`found ${conversations.length} resolved conversations`); -if (conversations.length > 0) { - const messages = await fetchRawMessages(creds, conversations[0].session_id); - console.log(JSON.stringify(messages[0], null, 2)); - console.log("--- conversation object keys ---"); - console.log(JSON.stringify(Object.keys(conversations[0]))); - console.log(JSON.stringify(conversations[0], null, 2)); -} From b1a5ec5d0574116f5bd34c0a9f534b21e8571582 Mon Sep 17 00:00:00 2001 From: tg-autopilot Date: Fri, 11 Sep 2026 11:22:11 +0545 Subject: [PATCH 2/2] chore: minimize comments in crisp-classify.mjs Co-Authored-By: Claude Sonnet 5 --- scripts/crisp-classify.mjs | 81 ++++++++++---------------------------- 1 file changed, 20 insertions(+), 61 deletions(-) diff --git a/scripts/crisp-classify.mjs b/scripts/crisp-classify.mjs index 20cec5e..140708a 100644 --- a/scripts/crisp-classify.mjs +++ b/scripts/crisp-classify.mjs @@ -1,22 +1,9 @@ #!/usr/bin/env node -// Stage 1 of the Crisp -> AI -> GitHub issue pipeline. -// -// Policy: a resolved conversation is trusted as fully handled by support -- -// resolving it (first time or Nth time) never triggers investigation on its -// own. The only way a previously-resolved conversation gets investigated is -// if it reopens (goes active again) before its next resolve; the resolved- -// conversation loop below only records "seen resolved at T", and the -// active-conversation loop investigates a reopen using messages after T. If -// it gets resolved again before we ever see it active in between, it's -// never investigated at all -- that falls out naturally from this loop -// always just re-recording the (later) resolve time, no special-casing -// needed. See PHASE2-SETUP.md § 4c for the auto-escalation policy for -// conversations that were never resolved in the first place, and the manual -// "!tg-autopilot investigate" note, which always overrides all of this. -// -// Writes matrix.json (Stage 2's input), state/cursor.json, -// state/escalated.json, and state/resolved-seen.json (per-session -// bookkeeping so none of the above repeats itself). +// Stage 1 of the Crisp -> AI -> GitHub issue pipeline. A resolved +// conversation is trusted as fully handled by support and never +// investigated on its own -- only a reopen, a manual note, or a +// stale-never-resolved conversation can trigger Stage 2. See +// PHASE2-SETUP.md § 4c for the full policy. // // Provider: OpenAI, swappable freely. Verify CLASSIFY_MODEL against your // account before relying on it. @@ -46,21 +33,17 @@ async function main() { const runStartedAt = new Date().toISOString(); const cursor = JSON.parse(await readFile("state/cursor.json", "utf8")); const accounts = JSON.parse(await readFile("config/inbox-to-repo.json", "utf8")).accounts; - // Skip anything the active-conversation dedupe check already matched to a - // tracked issue, to avoid a second redundant comment. + // Avoid a redundant comment for anything the dedupe check already matched. const activeNotified = new Set( JSON.parse(await readFile("state/active-notified.json", "utf8").catch(() => "[]")) ); // { [session_id]: { autoEscalated: bool, manualNoteCount: number } } const escalated = JSON.parse(await readFile("state/escalated.json", "utf8").catch(() => "{}")); - // session_ids already fully investigated once (any path) -- prevents a - // resolve/reopen/resolve cycle from reinvestigating forever. Not checked - // on the manual-note path; that's an intentional re-trigger. + // session_ids already fully investigated once. Not checked for manual notes. const investigated = new Set( JSON.parse(await readFile("state/investigated.json", "utf8").catch(() => "[]")) ); - // { [session_id]: } -- - // the only signal that lets the active loop below recognize a reopen. + // { [session_id]: } const resolvedSeen = JSON.parse(await readFile("state/resolved-seen.json", "utf8").catch(() => "{}")); const matrix = []; @@ -71,8 +54,7 @@ async function main() { let autoEscalations = 0; let reopenEscalations = 0; - // Each Crisp account (different logins) is fetched/classified independently; - // one not yet credentialed is skipped with a warning, not a crash. + // Each Crisp account is handled independently; an uncredentialed one just warns. for (const [accountKey, accountConfig] of Object.entries(accounts)) { let creds; try { @@ -82,8 +64,7 @@ async function main() { continue; } - // ---- Resolved conversations: record as seen, never investigate here ---- - // except for an explicit manual note, which always overrides the policy. + // Resolved conversations: just record as seen, unless a manual note overrides it. const conversations = await fetchResolvedConversationsSince(creds, cursor.last_checked); totalFetched += conversations.length; console.log(`[${accountKey}] fetched ${conversations.length} resolved conversations since ${cursor.last_checked}`); @@ -94,8 +75,7 @@ async function main() { continue; } - // Fetch raw messages (not just the transcript) so a manual note can be - // counted below without a second Crisp call. + // Raw messages, not just transcript, so we can also count manual notes. const messages = await fetchRawMessages(creds, conversation.session_id); const transcript = transcriptFrom(messages); @@ -119,20 +99,12 @@ async function main() { } } - // Record every resolved conversation as seen, regardless of the - // manual-note branch above -- a later reopen is measured from - // whichever resolve happened most recently. + // Always record the resolve, even if a manual note just fired above. resolvedSeen[conversation.session_id] = conversation.updated_at ?? Date.now(); } - // ---- Active conversations: manual note, reopen, and time-based escalation ---- - // Independent of crisp-dedupe-active.mjs, which skips anything that - // ends up in matrix.json this run. - // - // Only fetch messages for conversations touched since cursor.last_checked - // (adding a note is itself an update) -- cheaper than fetching every - // active conversation's history. Staleness/reopen detection need no - // message fetch at all. + // Active conversations: manual note, reopen, or time-based escalation. + // Separate from crisp-dedupe-active.mjs, which skips anything in matrix.json. const lastCheckedMs = new Date(cursor.last_checked).getTime(); const activeConversations = await fetchActiveConversations(creds); activeConversations.forEach((conversation) => { @@ -140,10 +112,7 @@ async function main() { conversation._checkManualNote = lastActiveAt > lastCheckedMs; }); - // fetchActiveConversations' page cap can bury a manually-noted - // conversation on a high-volume account -- search directly for the - // trigger phrase so a manual note is never missed. Anything found only - // here is appended and always checked this run. + // Search directly for the trigger phrase too, in case the page cap buries it. const seenSessionIds = new Set(activeConversations.map((c) => c.session_id)); const manualTriggerHits = await searchConversationsForManualTrigger(creds, "!tg-autopilot investigate"); for (const hit of manualTriggerHits) { @@ -157,8 +126,7 @@ async function main() { const record = escalated[conversation.session_id] ?? { autoEscalated: false, manualNoteCount: 0 }; const previousResolveAt = resolvedSeen[conversation.session_id]; - // active.last, not created_at: a reopened thread gets a fresh grace - // period rather than reading as ancient. Pure metadata, no message fetch. + // active.last, not created_at, so a reopened thread isn't read as ancient. const lastActiveAt = conversation.active?.last ?? conversation.created_at; const staleHours = (Date.now() - lastActiveAt) / (1000 * 60 * 60); const eligibleForAutoEscalate = @@ -167,10 +135,7 @@ async function main() { staleHours >= AUTO_ESCALATE_HOURS && staleHours <= AUTO_ESCALATE_MAX_HOURS; - // Previously seen resolved, now active again -- a reopen. Checked - // regardless of staleness/manual note: if it resolves again before we - // ever catch it here, the resolved-conversation loop above just - // records the later resolve time and this never fires for that gap. + // Seen resolved before, active again now -- a reopen. const isReopen = previousResolveAt !== undefined; if (!conversation._checkManualNote && !eligibleForAutoEscalate && !isReopen) continue; @@ -181,9 +146,7 @@ async function main() { if (!hasNewManualNote && !eligibleForAutoEscalate && !isReopen) continue; - // A reopen only looks at what's new since the previous resolve (a - // manual note overrides this and always gets the full transcript, same - // as everywhere else this pipeline honors that note). + // A reopen only sees what's new since the previous resolve; a manual note gets everything. const relevantMessages = isReopen && !hasNewManualNote ? messages.filter((m) => (m.timestamp ?? 0) > previousResolveAt) : messages; @@ -191,7 +154,6 @@ async function main() { if (!transcript.trim()) continue; if (hasNewManualNote) { - // No investigated guard -- a manual re-trigger should always go through. const result = await classifyAndRoute(accountConfig, conversation, transcript, { skipClassifier: true }); record.manualNoteCount = manualNoteCount; if (result.repo) { @@ -210,9 +172,7 @@ async function main() { reopenEscalations++; console.log(`[${accountKey}] ${conversation.session_id}: reopened after resolve, classifier agrees -> escalated to ${result.repo}`); } - // Advance the marker to the newest message we just looked at, so a - // conversation that's still open (not yet resolved again) doesn't - // get the same content re-classified on the next run. + // Advance the marker so an unresolved reopen doesn't get re-classified next run. const newestMs = messages.reduce((max, m) => Math.max(max, m.timestamp ?? 0), previousResolveAt); resolvedSeen[conversation.session_id] = newestMs; } else if (eligibleForAutoEscalate) { @@ -230,8 +190,7 @@ async function main() { } } - // Both loops can claim the same session_id in one run (e.g. a manual note - // reopens a just-resolved conversation). Dedupe, keeping the first entry seen. + // Both loops can claim the same session_id in one run -- dedupe, keep the first. const seenSessionIds = new Set(); const dedupedMatrix = matrix.filter((entry) => { if (seenSessionIds.has(entry.session_id)) return false;