wulf-pulse/lib/services/analyzer/asset-audit/prompt.ts
lorentz 1112a06afe feat: RMM Overshell, IT Glue audit/write-back, LogLift, link-aware bundles, dashboard overhaul
- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target
  resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift)
- LogLift evidence pipeline (migration 078): upload webhook, B2 storage client,
  receiver/matcher, EventLogCollector PowerShell script
- IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket
  xrefs, applications/configurations browse pages + apply/revert/audit endpoints
- Link-aware analyzer bundles (migration 073) + provider toggle (migration 074):
  link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion
  panels, analyze-bundle endpoint
- Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts
  admin page, reconciler service, resolve endpoints
- Dashboard overhaul: integration-health service + alerts, overview/health endpoints
- Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:13:18 -04:00

236 lines
11 KiB
TypeScript

/**
* System prompt + user payload builder for the IT Glue asset audit stage.
*
* The output schema is `AssetAuditResponse` from `lib/types/analyzer.ts` —
* field gaps, notes promotions, contradictions, overall score. The prompt
* is deliberately tight on what counts as a "gap":
* - empty field that other tickets needed → high confidence
* - empty field with no ticket evidence → suggested_value: null, low/medium
* (LLM may flag as opportunity but with no concrete value)
*
* Provider-agnostic: the same prompt runs on Claude Sonnet or DeepSeek V4
* Pro via callLLMStage. Phase 4.1 supports both Application (flexible
* asset) and Configuration audits via assetType-aware prompt selection.
*/
import type { AssetAuditContext } from './data-builder';
const LIVE_EVIDENCE_NOTE = `When a "LIVE RMM EVIDENCE" section is present, treat its parsed contents as authoritative current state of the environment, captured by remote PowerShell within the last few days. Use it to justify suggested values with high confidence — e.g. if Get-Services lists "BartenderProcessService" running on the target and a ticket asked about BarTender printing, suggest adding that service name to operating_system_notes with confidence=high. Cite execution_id alongside ticket numbers in evidence_ticket_numbers (it's fine to mix them).
When a "loglift-eventlogs" evidence row is present, the parsed_evidence contains a slim view of a Windows event-log + system-context capture: system_context (OS, hardware, uptime, last boot, pending reboot, memory, disks, recent updates), summary (TotalEvents / CriticalEvents / ByLevel / TimeRange / TopEventIds), and top_events — the highest-severity events sorted Critical → Error → Warning → Information, then most-recent. event_count_total is the original count; top_events is capped at 100. When citing event evidence in evidence_ticket_numbers it's fine to write "event:<EventId>" or "execution:<execution_id>". Do NOT claim "no errors observed" if event_count_total is large — say "of the top events captured" instead. Treat system_context as authoritative for OS / hardware / disk / memory facts on the matched Configuration.
`;
const COMMON_RULES = `${LIVE_EVIDENCE_NOTE}
Categorize findings into three buckets:
1. **field_gaps** — empty or anemic fields that, given the ticket evidence, would have measurably helped a tech diagnose or escalate faster. For each:
- field_name: the EXACT field name from the provided schema (do not invent fields)
- why_missing_matters: 1 sentence connecting the gap to a real ticket scenario
- suggested_value: a concrete value derived from the ticket evidence, or null if you cannot infer one with high confidence
- evidence_ticket_numbers: tickets that demonstrate the need
- confidence: high (clear evidence + suggestion), medium (clear gap, weaker suggestion), low (opportunity, no concrete value)
2. **notes_promotions** — substrings of the existing free-text Notes / Operating-System-Notes field that are actually structured data and belong in a dedicated field. For each:
- quoted_note_text: the exact substring from the Notes field
- target_field: the field where it belongs (must exist in schema)
- suggested_value: how the value should look in the structured field
- confidence
3. **contradictions** — places where the record's fields disagree with each other or with ticket evidence (e.g. Notes say "2-3 VMs" but only 1 VM is tagged; OS field says Server 2016 but a ticket recently mentioned PowerShell 7 features).
Rules:
- Never invent fields that aren't in the provided schema.
- Never suggest a value you cannot point to evidence for. Use null instead.
- Prefer high signal over volume — 3 strong gaps beats 10 generic ones.
- The "fill_rate" stats tell you what's normal for this asset type. A field that's empty here but populated >80% of the time elsewhere is a stronger gap than one that's empty 80% of the time across all clients.
- DO NOT suggest values for password, secret, key, token, or credential fields — those are out of scope.
- overall_score is your 0-1 self-rated assessment of how complete the record is for diagnostic purposes (1 = nothing missing, 0 = empty).
Respond ONLY with JSON. No prose, no code fences.
Schema:
{
"field_gaps": [{"field_name": str, "why_missing_matters": str, "suggested_value": str | null, "evidence_ticket_numbers": str[], "confidence": "high" | "medium" | "low"}],
"notes_promotions": [{"quoted_note_text": str, "target_field": str, "suggested_value": str, "confidence": "high" | "medium" | "low"}],
"contradictions": [{"description": str, "evidence": str}],
"overall_score": number
}`;
const FLEXIBLE_ASSET_PROMPT = `You are auditing an IT Glue **flexible-asset** record (typically an Application) for Wulf Consulting, an MSP. Your job is to identify what should be documented in this record based on (a) the asset type's field schema with hints, (b) what comparable records contain, (c) what tickets actually needed to know.
${COMMON_RULES}`;
const CONFIGURATION_PROMPT = `You are auditing an IT Glue **Configuration** record (a server, workstation, network device, etc.) for Wulf Consulting, an MSP. Your job is to identify what should be documented in this record based on (a) the field schema with hints below, (b) what comparable Configuration records contain, (c) what tickets actually needed to know.
Configuration audits care especially about:
- **Hostname / FQDN consistency** — the name field, hostname, and what tickets call the device should agree.
- **Operating-system currency** — OS version drives patch posture, support tier, escalation path.
- **Named services** — when tickets resolve by restarting or fixing a specific Windows service, that service name should be captured (in operating_system_notes ideally) so the next tech finds it without grepping ticket history.
- **Networking facts** — primary IP, MAC, position. If tickets reveal an IP change or a new NIC, surface it.
- **Architecture relationships** — which apps run on this server, which integrations flow through it. If the Notes field is the only place this lives, flag a notes_promotion to a more visible field where the schema allows.
- **Contact ownership** — workstations should have an end-user contact; servers should have a champion or responsible team.
The 'name' field is the IT Glue display name. 'hostname' is the technical name on the network. They are often the same; flag when they disagree.
${COMMON_RULES}`;
const TICKET_SCOPED_SUFFIX = `
THIS AUDIT IS SCOPED TO A SINGLE TICKET. The ticket evidence section contains exactly one analysis — the ticket the user just analyzed. Frame your gaps as "what did this ticket teach us that the documentation doesn't say?" rather than all-time history. Cite ticket numbers, not theoretical scenarios.`;
const PAYLOAD_CHAR_CAP = 80_000;
export function getSystemPrompt(ctx: AssetAuditContext): string {
const base =
ctx.asset_type === 'configuration'
? CONFIGURATION_PROMPT
: FLEXIBLE_ASSET_PROMPT;
return ctx.ticket_scope ? base + TICKET_SCOPED_SUFFIX : base;
}
/**
* Build the user payload. If we exceed the size cap (rare), trim the
* peer_global section first (least-load-bearing), then drop older ticket
* evidence one at a time. Schema and fill rates are never dropped — they're
* the lookup table the LLM needs to answer correctly.
*/
export function buildAssetAuditUserPayload(ctx: AssetAuditContext): {
payload: string;
trimmed: { peer_global_dropped: number; tickets_dropped: number; rmm_dropped: number };
} {
const trimmed = { peer_global_dropped: 0, tickets_dropped: 0, rmm_dropped: 0 };
const peerGlobal = ctx.peer_global.slice();
const tickets = ctx.ticket_evidence.slice();
const rmmEvidence = (ctx.rmm_evidence ?? []).slice();
const assetTypeLabel =
ctx.asset_type === 'configuration'
? 'CONFIGURATION'
: 'FLEXIBLE ASSET';
const ticketHeader = ctx.ticket_scope
? `=== TICKET EVIDENCE (single ticket — this audit is scoped to ${ctx.ticket_scope.ticket_number}) ===`
: `=== TICKET EVIDENCE (this client, recent, mentions of the asset) ===`;
function render(): string {
const sections: string[] = [
`=== ${assetTypeLabel} UNDER AUDIT ===`,
JSON.stringify(
{
id: ctx.asset.id,
name: ctx.asset.name,
organization_name: ctx.asset.organization_name,
type_name: ctx.type_name,
fields: ctx.asset.traits,
},
null,
2
),
``,
`=== FIELD SCHEMA (with hints) ===`,
JSON.stringify(ctx.fields, null, 2),
``,
`=== FILL-RATE STATS ===`,
JSON.stringify(
{
this_client: ctx.fill_rate_client,
across_all_clients: ctx.fill_rate_global,
},
null,
2
),
``,
`=== PEER EXEMPLARS — SAME CLIENT ===`,
JSON.stringify(
ctx.peer_same_client.map((p) => ({
id: p.id,
name: p.name,
fields: p.traits,
})),
null,
2
),
``,
`=== PEER EXEMPLARS — BEST-IN-CLASS ACROSS ALL CLIENTS ===`,
JSON.stringify(
peerGlobal.map((p) => ({
id: p.id,
name: p.name,
organization_name: p.organization_name,
fields: p.traits,
})),
null,
2
),
];
if (rmmEvidence.length > 0) {
sections.push(
``,
`=== LIVE RMM EVIDENCE (most recent successful Overshell runs; AUTHORITATIVE current state) ===`,
JSON.stringify(
rmmEvidence.map((e) => ({
execution_id: e.execution_id,
script_id: e.script_id,
target_type: e.target_type,
target_hostname: e.target_hostname,
captured_at: e.captured_at,
parsed: e.parsed,
})),
null,
2
)
);
}
sections.push(
``,
ticketHeader,
JSON.stringify(
tickets.map((t) => ({
ticket_number: t.ticket_number,
triggered_at: t.triggered_at,
summary: t.summary,
fingerprint: t.fingerprint,
})),
null,
2
)
);
return sections.join('\n');
}
let payload = render();
while (payload.length > PAYLOAD_CHAR_CAP) {
// Drop in this priority order: peer_global → ticket_evidence (oldest) →
// rmm_evidence (least recent first). RMM evidence drops last because
// it's the highest-value live data.
if (peerGlobal.length > 0) {
peerGlobal.pop();
trimmed.peer_global_dropped += 1;
} else if (tickets.length > 0) {
tickets.shift();
trimmed.tickets_dropped += 1;
} else if (rmmEvidence.length > 0) {
rmmEvidence.pop();
trimmed.rmm_dropped += 1;
} else {
break;
}
payload = render();
}
return { payload, trimmed };
}
// Back-compat export for tests + any external callers that still import the
// flexible-asset prompt directly.
export const SYSTEM_PROMPT = FLEXIBLE_ASSET_PROMPT;
export const _PROMPT_INTERNALS = {
PAYLOAD_CHAR_CAP,
FLEXIBLE_ASSET_PROMPT,
CONFIGURATION_PROMPT,
TICKET_SCOPED_SUFFIX,
};