diff --git a/README.md b/README.md index 8573070..6183abc 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ Every finding contains: - severity and confidence; - an estimated hour range. -Use the filters to focus on **All findings**, **High risk** or **Unreviewed**. Open **Review finding** to add an optional note and make a decision. +Use the filters to separate **Scope drift**, **Commercial risks**, **High risk** and **Unreviewed** items. A cancellation or payment signal is not a scope verdict; it is a follow-up queue for finance or sales. Open **Review finding** to add an optional note and make a decision. ### 6. Decide what happens next diff --git a/docs/implementation-notes.md b/docs/implementation-notes.md index a009217..43ff97c 100644 --- a/docs/implementation-notes.md +++ b/docs/implementation-notes.md @@ -9,9 +9,9 @@ The current slice is a responsive React/Vite local-first review workspace, a det - No external UI library yet; the surface is small and the visual language is custom. - Sources are normalized into `SourceDocument` records with a scope/messages/unknown kind and an explicit format. - TXT/MD, RTF-in-MD, EML and common Telegram, WhatsApp, Facebook Messenger and Slack JSON exports are parsed locally in `src/analysis.ts`. -- The analyzer runs deterministic rules for new deliverables, acceptance criteria, extra revisions and unpriced commitments. +- The analyzer runs deterministic rules for new deliverables, acceptance criteria, extra revisions, unpriced commitments and commercial risks such as cancellation, payment, availability and customer delay. - Rule patterns, rule signal strength, severity and hour ranges are configured in `src/rules.yaml` and validated at build time. -- “Run analysis” now recomputes findings, the percentage of messages with a scope basis, message count and preliminary exposure from the current sources. +- “Run analysis” now recomputes findings, commercial-risk count, the percentage of messages with a scope basis, message count and preliminary exposure from the current sources. - PDF/DOCX are rejected with an explicit next-adapter message instead of pretending they were parsed. CSV/XLSX and CRM/ERP records are deferred to private add-ons; Slack/Gmail/WhatsApp connectors are the next open-source pilot slice. - Findings are explicitly evidence-backed and use “potential” language. - A reviewer can add a note, mark a finding in scope, create a change request decision, reopen it, and use the unreviewed filter as a queue. @@ -22,6 +22,7 @@ The current slice is a responsive React/Vite local-first review workspace, a det - The pilot visual system remains the original warm paper/ink/orange workspace design; the page header and demo banner have explicit layout styles so pilot-state additions do not fall back to browser defaults. - An initial order email can be used as the scope source: order/request-style filenames are scope candidates, and manually reclassified prose emails produce scope items without requiring Markdown bullets. - RTF order exports saved with an `.md` extension are normalized locally; cancellation/reply email filenames stay communication candidates even when their quoted history mentions order totals. +- Commercial risks are intentionally separate from scope drift: they do not add fake delivery hours and use a follow-up action instead of a scope clause. ## Next technical slice diff --git a/docs/page-strategy.md b/docs/page-strategy.md index 33953ae..ef84717 100644 --- a/docs/page-strategy.md +++ b/docs/page-strategy.md @@ -16,8 +16,8 @@ ScopeGuard is a local-first evidence review tool for small agencies. It compares - project context and saved-local status; - clear analysis state; -- share of messages with a scope basis and preliminary exposure hours; -- evidence list with filters for all, high risk and unreviewed; +- share of messages with a scope basis, preliminary exposure hours and commercial-risk count; +- evidence list with filters for all, scope drift, commercial risks, high risk and unreviewed; - source list with add/drop affordance; - local-first privacy reassurance; - no legal verdict language. diff --git a/docs/product-status.md b/docs/product-status.md index 062c402..f5dac38 100644 --- a/docs/product-status.md +++ b/docs/product-status.md @@ -115,6 +115,8 @@ Each finding supports: Available filters: - all findings; +- scope drift only; +- commercial risks only; - high risk; - unreviewed only. diff --git a/src/analysis.ts b/src/analysis.ts index c77d121..04ace99 100644 --- a/src/analysis.ts +++ b/src/analysis.ts @@ -6,6 +6,8 @@ import type { ConnectorMessage, MessageRole, PilotChannel } from './connectors' export type SourceKind = 'scope' | 'messages' | 'unknown' export type SourceFormat = 'txt' | 'md' | 'eml' | 'json' | 'pdf' | 'docx' | 'unknown' export type ScopeMatch = 'included' | 'excluded' | 'ambiguous' | 'none' +export type FindingCategory = 'scope_drift' | 'commercial_risk' +export type CommercialRiskKind = 'cancellation' | 'payment_risk' | 'availability_risk' | 'customer_delay' export type SourceDocument = { id: string @@ -18,6 +20,8 @@ export type SourceDocument = { export type Finding = { id: string + category: FindingCategory + riskKind?: CommercialRiskKind type: string title: string excerpt: string @@ -38,6 +42,7 @@ export type AnalysisResult = { messagesWithScopeBasis: number scopeCoverage: number hoursAtRisk: string + commercialRiskCount: number unsupportedSources: string[] } @@ -63,9 +68,11 @@ type MessageRecord = { type RuleConfig = { id: string + category: FindingCategory + riskKind?: CommercialRiskKind type: string pattern: string - titleStyle: 'subject_not_in_scope' | 'changes_expectation' | 'extra_revision' | 'unpriced_commitment' + titleStyle: 'subject_not_in_scope' | 'changes_expectation' | 'extra_revision' | 'unpriced_commitment' | 'commercial_risk' severity: Finding['severity'] confidence: number minHours: number @@ -167,9 +174,11 @@ export function analyzeSources(sources: SourceDocument[]): AnalysisResult { const scopeItems = scopeSources.flatMap((source) => extractScopeItems(source.id, source.content)) const messages = messageSources.flatMap((source) => extractMessages(source)) const findings = messages.flatMap((message) => createFindings(message, scopeItems)) - const totalMin = findings.reduce((sum, finding) => sum + parseHours(finding.hours)[0], 0) - const totalMax = findings.reduce((sum, finding) => sum + parseHours(finding.hours)[1], 0) - const messagesWithScopeBasis = messages.filter((message) => rules.some((rule) => rule.pattern.test(message.text) && findScopeBasis(rule, message.text, scopeItems).status !== 'none')).length + const scopeFindings = findings.filter((finding) => finding.category === 'scope_drift') + const totalMin = scopeFindings.reduce((sum, finding) => sum + parseHours(finding.hours)[0], 0) + const totalMax = scopeFindings.reduce((sum, finding) => sum + parseHours(finding.hours)[1], 0) + const scopeRules = rules.filter((rule) => rule.category === 'scope_drift') + const messagesWithScopeBasis = messages.filter((message) => scopeRules.some((rule) => rule.pattern.test(message.text) && findScopeBasis(rule, message.text, scopeItems).status !== 'none')).length const coverage = messages.length === 0 ? 0 : Math.round((messagesWithScopeBasis / messages.length) * 100) return { @@ -178,7 +187,8 @@ export function analyzeSources(sources: SourceDocument[]): AnalysisResult { messagesCompared: messages.length, messagesWithScopeBasis, scopeCoverage: coverage, - hoursAtRisk: findings.length ? `${totalMin}–${totalMax}h` : '0h', + hoursAtRisk: scopeFindings.length ? `${totalMin}–${totalMax}h` : '0h', + commercialRiskCount: findings.filter((finding) => finding.category === 'commercial_risk').length, unsupportedSources: sources.filter((source) => source.kind === 'unknown').map((source) => source.name), } } @@ -380,9 +390,14 @@ function normalizeMessage(source: SourceDocument, message: ConnectorMessage, ind function createFindings(message: MessageRecord, scopeItems: ScopeItem[]): Finding[] { return rules.flatMap((rule) => { if (!rule.pattern.test(message.text)) return [] - if (rule.id === 'unpriced_commitment' && message.role === 'client') return [] - if (isNegatedSignal(rule, message.text)) return [] + if (rule.category === 'scope_drift') { + if (rule.id === 'unpriced_commitment' && message.role === 'client') return [] + if (isNegatedSignal(rule, message.text)) return [] + } else if (isNegatedCommercialSignal(rule, message.text)) { + return [] + } + if (rule.category === 'commercial_risk') return [createCommercialFinding(rule, message)] const scopeMatch = findScopeBasis(rule, message.text, scopeItems) if (rule.id === 'new_deliverable' && scopeMatch.status === 'included') return [] @@ -390,9 +405,10 @@ function createFindings(message: MessageRecord, scopeItems: ScopeItem[]): Findin const severity = adjustedSeverity(rule, scopeMatch.status) return [{ id: `SG-${stableHash(`${message.id}|${rule.id}|${normalizeForMatching(message.text)}`)}`, + category: 'scope_drift', type: rule.type, title: titleFromRule(rule.titleStyle, message.text), - excerpt: `“${message.text}”`, + excerpt: findingExcerpt(rule, message.text), source: message.author ? `${message.source} · ${message.author}` : message.source, scope: scopeMatch.label, scopeMatch: scopeMatch.status, @@ -405,6 +421,61 @@ function createFindings(message: MessageRecord, scopeItems: ScopeItem[]): Findin }) } +function createCommercialFinding(rule: Rule, message: MessageRecord): Finding { + const riskKind = rule.riskKind ?? 'customer_delay' + return { + id: `SG-${stableHash(`${message.id}|${rule.id}|${normalizeForMatching(message.text)}`)}`, + category: 'commercial_risk', + riskKind, + type: rule.type, + title: commercialRiskTitle(riskKind), + excerpt: findingExcerpt(rule, message.text), + source: message.author ? `${message.source} · ${message.author}` : message.source, + scope: 'Commercial signal · Not a scope clause', + scopeMatch: 'none', + hours: '—', + confidence: rule.confidence, + severity: rule.severity, + reviewed: false, + decision: 'pending', + } +} + +function commercialRiskTitle(kind: CommercialRiskKind): string { + if (kind === 'cancellation') return 'Order appears cancelled or paused' + if (kind === 'payment_risk') return 'Payment status may block the order' + if (kind === 'availability_risk') return 'Product availability may block delivery' + return 'Customer may be delaying the order' +} + +function findingExcerpt(rule: Rule, text: string): string { + const match = rule.pattern.exec(text) + const matchIndex = match?.index ?? 0 + const radiusBefore = rule.category === 'commercial_risk' ? 60 : 120 + const radiusAfter = rule.category === 'commercial_risk' ? 140 : 220 + const start = Math.max(0, matchIndex - radiusBefore) + const end = Math.min(text.length, matchIndex + (match?.[0].length ?? 0) + radiusAfter) + const prefix = start > 0 ? '…' : '' + const suffix = end < text.length ? '…' : '' + const excerpt = sanitizeEvidence(text.slice(start, end)) + return `“${prefix}${excerpt}${suffix}”` +} + +function sanitizeEvidence(value: string): string { + return value + .replace(/https?:\/\/\S+/gi, '[link]') + .replace(/[\w.+-]+@[\w.-]+\.[a-z]{2,}/gi, '[email]') + .replace(/(?:\+?\d[\d\s().-]{7,}\d)/g, '[phone]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 360) +} + +function isNegatedCommercialSignal(rule: Rule, text: string): boolean { + if (rule.riskKind !== 'cancellation') return false + return /\b(?:not|never|no)\s+cancell?ed?\b/i.test(text) +} + function findScopeBasis(rule: Rule, text: string, scopeItems: ScopeItem[]): { status: ScopeMatch; label: string } { const normalizedText = normalizeForMatching(text) const textTokens = new Set(tokensFrom(normalizedText)) @@ -463,6 +534,7 @@ function titleFromRule(style: RuleConfig['titleStyle'], text: string): string { if (style === 'subject_not_in_scope') return `${extractSubject(text)} is not in the agreed scope` if (style === 'changes_expectation') return `${extractSubject(text)} changes the delivery expectation` if (style === 'extra_revision') return 'An additional revision round was requested' + if (style === 'commercial_risk') return 'A commercial risk signal was detected' return 'A delivery commitment appears without a matching price' } @@ -482,9 +554,11 @@ function loadRules(text: string): Rule[] { function isRuleConfig(value: Record): value is Record & RuleConfig { return typeof value.id === 'string' + && ['scope_drift', 'commercial_risk'].includes(String(value.category)) && typeof value.type === 'string' && typeof value.pattern === 'string' && typeof value.titleStyle === 'string' + && (value.category !== 'commercial_risk' || ['cancellation', 'payment_risk', 'availability_risk', 'customer_delay'].includes(String(value.riskKind))) && ['high', 'medium', 'low'].includes(String(value.severity)) && typeof value.confidence === 'number' && typeof value.minHours === 'number' diff --git a/src/connectors.ts b/src/connectors.ts index 372e3b5..65ad9cb 100644 --- a/src/connectors.ts +++ b/src/connectors.ts @@ -171,7 +171,21 @@ function selectEmailText(contentType: string | undefined, transferEncoding: stri function decodeTransferEncoding(value: string, encoding: string | undefined): string { if (/quoted-printable/i.test(encoding ?? '')) { - return value.replace(/=\r?\n/g, '').replace(/=([0-9A-F]{2})/gi, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16))) + const bytes: number[] = [] + const normalized = value.replace(/=\r?\n/g, '') + for (let index = 0; index < normalized.length; index += 1) { + if (normalized[index] === '=' && /^[0-9A-F]{2}$/i.test(normalized.slice(index + 1, index + 3))) { + bytes.push(Number.parseInt(normalized.slice(index + 1, index + 3), 16)) + index += 2 + } else { + bytes.push(normalized.charCodeAt(index)) + } + } + try { + return new TextDecoder('utf-8').decode(Uint8Array.from(bytes)) + } catch { + return String.fromCharCode(...bytes) + } } if (/base64/i.test(encoding ?? '')) { try { diff --git a/src/main.tsx b/src/main.tsx index 035763e..0e9f2dd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -12,7 +12,7 @@ const persistedWorkspace = loadWorkspace() function App() { const [activeProject, setActiveProject] = useState(persistedWorkspace?.projectName ?? 'Acme launch site') - const [filter, setFilter] = useState<'all' | 'high' | 'unreviewed'>('all') + const [filter, setFilter] = useState<'all' | 'scope' | 'commercial' | 'high' | 'unreviewed'>('all') const [findings, setFindings] = useState(persistedWorkspace?.findings ?? initialAnalysis.findings) const [sources, setSources] = useState(persistedWorkspace?.sources ?? demoSources) const [analysis, setAnalysis] = useState(persistedWorkspace?.analysis ?? initialAnalysis) @@ -39,6 +39,8 @@ function App() { const visibleFindings = useMemo(() => { if (!analysisReady) return [] + if (filter === 'scope') return findings.filter((finding) => finding.category === 'scope_drift') + if (filter === 'commercial') return findings.filter((finding) => finding.category === 'commercial_risk') if (filter === 'high') return findings.filter((finding) => finding.severity === 'high') if (filter === 'unreviewed') return findings.filter((finding) => !finding.reviewed) return findings @@ -208,12 +210,12 @@ function App() {
{analysisReady ? '✓' : '…'}
{analysisReady ? 'Analysis complete' : sources.length ? 'Ready to analyse' : 'Add project sources'}{analysisReady ? `Compared ${analysis.messagesCompared} messages against ${analysis.scopeItemsCount} scope items` : `${sources.length} source${sources.length === 1 ? '' : 's'} loaded · Run analysis after adding both source types`}
-
With scope basis{analysisReady ? `${analysis.scopeCoverage}%` : '—'}
Preliminary exposure{analysisReady ? analysis.hoursAtRisk : '—'}
Unreviewed{analysisReady ? findings.filter((finding) => !finding.reviewed).length : '—'}
+
With scope basis{analysisReady ? `${analysis.scopeCoverage}%` : '—'}
Preliminary exposure{analysisReady ? analysis.hoursAtRisk : '—'}
Commercial risks 0 ? 'orange-text' : ''}>{analysisReady ? analysis.commercialRiskCount : '—'}
Unreviewed{analysisReady ? findings.filter((finding) => !finding.reviewed).length : '—'}
-

EVIDENCE REVIEW

Potential scope drift

{(['all', 'high', 'unreviewed'] as const).map((item) => )}
+

EVIDENCE REVIEW

Findings and risks

{(['all', 'scope', 'commercial', 'high', 'unreviewed'] as const).map((item) => )}
{visibleFindings.map((finding) => )} - {!visibleFindings.length &&
{!analysisReady ? 'Sources changed — run analysis.' : findings.length ? 'Nothing waiting here.' : 'No potential scope drift found.'}{!analysisReady ? 'Previous findings are hidden until the current source set is analysed.' : findings.length ? 'Every finding in this view has been reviewed.' : 'Add another communication export if you want to check more conversations.'}
} + {!visibleFindings.length &&
{!analysisReady ? 'Sources changed — run analysis.' : findings.length ? 'Nothing waiting here.' : filter === 'commercial' ? 'No commercial risks found.' : filter === 'scope' ? 'No scope drift found.' : 'No findings or risks found.'}{!analysisReady ? 'Previous findings are hidden until the current source set is analysed.' : findings.length ? 'Every finding in this view has been reviewed.' : 'Add another communication export if you want to check more conversations.'}
}
@@ -264,12 +266,12 @@ function FindingCard({ onOpenReview: (id: string) => void onReopen: (id: string) => void }) { - return
+ return
{finding.type}{finding.id}

{finding.title}

{finding.excerpt}
-
Source{finding.source}Scope basis{finding.scope}
-
Preliminary exposure{finding.hours} 90 ? 'strong' : ''}`}>{finding.confidence}% rule signal
{finding.reviewed ?
{decisionLabel(finding.decision)}
: }
+
Source{finding.source}{finding.category === 'commercial_risk' ? 'Signal' : 'Scope basis'}{finding.scope}
+
{finding.category === 'commercial_risk' ? 'Next action' : 'Preliminary exposure'}{finding.category === 'commercial_risk' ? 'Follow up' : finding.hours} 90 ? 'strong' : ''}`}>{finding.confidence}% rule signal
{finding.reviewed ?
{decisionLabel(finding.decision)}
: }
{isReviewing && !finding.reviewed &&