diff --git a/boat/doc-collector/src/ai/documentarian.ts b/boat/doc-collector/src/ai/documentarian.ts index 74bc2c6..5f500c9 100644 --- a/boat/doc-collector/src/ai/documentarian.ts +++ b/boat/doc-collector/src/ai/documentarian.ts @@ -45,7 +45,7 @@ class Documentarian { try { tag('info').log('Starting interactive exploration...'); - const deterministicInteractions = await collectDocInteractions(this.explorer!, state, research); + const deterministicInteractions = await collectDocInteractions(this.explorer!, state, research, this.config); const meaningfulInteractions = this.getMeaningfulInteractions(deterministicInteractions); if (meaningfulInteractions.length > 0) { tag('success').log(`Collected ${meaningfulInteractions.length} deterministic interactions`); @@ -240,10 +240,15 @@ class Documentarian { } private normalizeDocumentation(documentation: PageDocumentation, _state: WebPageState, _research: string): PageDocumentation { - const qualityNotes = this.evaluateDocumentationQuality(documentation); + const normalized = { ...documentation }; + if (!normalized.interactions) { + normalized.interactions = undefined; + } + + const qualityNotes = this.evaluateDocumentationQuality(normalized); return { - ...documentation, + ...normalized, qualityNotes, }; } @@ -321,36 +326,55 @@ const stateTransitionSchema = z.object({ action: z.string(), before: z.string(), after: z.string(), - targetUrl: z.string().optional(), - discoveredUrls: z.array(z.string()).optional(), - newCapabilities: z.array(z.string()).optional(), + targetUrl: z.string().nullable(), + discoveredUrls: z.array(z.string()).nullable(), + newCapabilities: z.array(z.string()).nullable(), element: z .object({ role: z.string(), name: z.string(), section: z.string(), - container: z.string().optional(), - locator: z.string().optional(), + container: z.string().nullable(), + locator: z.string().nullable(), }) - .optional(), + .nullable(), changes: z .object({ urlChanged: z.boolean(), newElements: z.number(), removedElements: z.number(), }) - .optional(), + .nullable(), }); const pageDocumentationSchema = z.object({ summary: z.string(), can: z.array(capabilitySchema), might: z.array(capabilitySchema), - interactions: z.array(stateTransitionSchema).optional(), + interactions: z.array(stateTransitionSchema).nullable(), }); -type StateTransition = z.infer; -type PageDocumentation = z.infer & { +type StateTransition = { + action: string; + before: string; + after: string; + targetUrl?: string | null; + discoveredUrls?: string[] | null; + newCapabilities?: string[] | null; + element?: { + role: string; + name: string; + section: string; + container?: string | null; + locator?: string | null; + } | null; + changes?: { + urlChanged: boolean; + newElements: number; + removedElements: number; + } | null; +}; +type PageDocumentation = Omit, 'interactions'> & { interactions?: StateTransition[]; qualityNotes?: string[]; }; diff --git a/boat/doc-collector/src/ai/tools.ts b/boat/doc-collector/src/ai/tools.ts index 31750d8..95786a4 100644 --- a/boat/doc-collector/src/ai/tools.ts +++ b/boat/doc-collector/src/ai/tools.ts @@ -1,6 +1,7 @@ import { type ResearchElement, parseResearchSections } from '../../../../src/ai/researcher/parser.ts'; import type Explorer from '../../../../src/explorer.ts'; import type { WebPageState } from '../../../../src/state-manager.ts'; +import type { DocbotConfig } from '../config.ts'; export interface DocStateTransition { action: string; @@ -34,23 +35,25 @@ interface InteractionChanges { removedElements: number; } -const MAX_PRIMARY_CANDIDATES = 3; -const MAX_INTERACTIONS = 5; +const DEFAULT_MAX_PRIMARY_CANDIDATES = 3; +const DEFAULT_MAX_INTERACTIONS = 5; const MAX_LINKS = 15; const DEFAULT_WAIT_MS = 700; const TAB_WAIT_MS = 500; +const DEFAULT_DENIED_ACTION_LABELS = ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop']; -export async function collectDocInteractions(explorer: Explorer, state: WebPageState, research: string): Promise { +export async function collectDocInteractions(explorer: Explorer, state: WebPageState, research: string, config: DocbotConfig = {}): Promise { const sections = parseResearchSections(research); const transitions: DocStateTransition[] = []; + const maxInteractions = getPositiveConfigNumber(config.docs?.maxInteractions, DEFAULT_MAX_INTERACTIONS); const tabGroup = findTabGroup(sections); if (tabGroup) { - transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url))); + transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url, maxInteractions))); } - for (const candidate of findActionCandidates(sections)) { - if (transitions.length >= MAX_INTERACTIONS) { + for (const candidate of findActionCandidates(sections, config)) { + if (transitions.length >= maxInteractions) { break; } @@ -65,18 +68,22 @@ export async function collectDocInteractions(explorer: Explorer, state: WebPageS return transitions; } -export function pickDocActionCandidates(research: string): Array<{ label: string; role: InteractionCandidate['role']; section: string }> { - return findActionCandidates(parseResearchSections(research)).map((candidate) => ({ +export function pickDocActionCandidates(research: string, config: DocbotConfig = {}): Array<{ label: string; role: InteractionCandidate['role']; section: string }> { + return findActionCandidates(parseResearchSections(research), config).map((candidate) => ({ label: candidate.element.name.trim(), role: candidate.role, section: candidate.sectionName, })); } -async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: ResearchElement[]; container?: string; sectionName: string }, restoreUrl: string): Promise { +async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: ResearchElement[]; container?: string; sectionName: string }, restoreUrl: string, maxInteractions: number): Promise { const transitions: DocStateTransition[] = []; for (const element of tabGroup.elements) { + if (transitions.length >= maxInteractions) { + break; + } + const transition = await executeInteraction( explorer, { @@ -241,23 +248,21 @@ function findTabGroup(sections: ReturnType): { ele return null; } -function findActionCandidates(sections: ReturnType): InteractionCandidate[] { +function findActionCandidates(sections: ReturnType, config: DocbotConfig): InteractionCandidate[] { const candidates: InteractionCandidate[] = []; const seen = new Set(); const navigationLabels = collectNavigationLabels(sections); + const maxPrimaryCandidates = getPositiveConfigNumber(config.docs?.maxPrimaryCandidates, DEFAULT_MAX_PRIMARY_CANDIDATES); for (const section of sections) { const sectionName = section.name.toLowerCase(); const container = section.containerCss?.toLowerCase() || ''; - if (isOverlaySection(sectionName, container)) { - continue; - } - if (isNavigationSection(sectionName)) { + if (isIgnoredSection(sectionName, container)) { continue; } for (const element of section.elements) { - const candidate = toInteractionCandidate(element, section.name, section.containerCss, navigationLabels); + const candidate = toInteractionCandidate(element, section.name, section.containerCss, navigationLabels, config); if (!candidate) { continue; } @@ -272,10 +277,10 @@ function findActionCandidates(sections: ReturnType } } - return candidates.sort((a, b) => scoreCandidate(b) - scoreCandidate(a)).slice(0, MAX_PRIMARY_CANDIDATES); + return candidates.sort((a, b) => scoreCandidate(b) - scoreCandidate(a)).slice(0, maxPrimaryCandidates); } -function toInteractionCandidate(element: ResearchElement, sectionName: string, container: string | null | undefined, navigationLabels: Set): InteractionCandidate | null { +function toInteractionCandidate(element: ResearchElement, sectionName: string, container: string | null | undefined, navigationLabels: Set, config: DocbotConfig): InteractionCandidate | null { const role = getElementRole(element); if (role !== 'link' && role !== 'button' && role !== 'tab') { return null; @@ -283,7 +288,10 @@ function toInteractionCandidate(element: ResearchElement, sectionName: string, c if (!hasUsableName(element)) { return null; } - if (isShellLocator(element.css) || isShellLocator(element.xpath) || isShellLocator(container)) { + if (isPageShellContainer(element.css) || isPageShellContainer(element.xpath)) { + return null; + } + if (isDestructiveAction(element, config)) { return null; } if (role === 'link' && navigationLabels.has(normalizeCandidateLabel(element.name))) { @@ -454,6 +462,20 @@ function isNavigationSection(sectionName: string): boolean { return /(navigation|menu|header|footer|breadcrumb)/i.test(sectionName); } +function isContentControlSection(sectionName: string): boolean { + return /(content|control|filter|toolbar|action|list|data)/i.test(sectionName); +} + +function isIgnoredSection(sectionName: string, container: string): boolean { + if (isOverlaySection(sectionName, container)) { + return true; + } + if (isContentControlSection(sectionName)) { + return false; + } + return isNavigationSection(sectionName) || isPageShellContainer(container); +} + function isOverlaySection(sectionName: string, container: string): boolean { return /(overlay|modal|popup|dialog)/i.test(sectionName) || /(overlay|modal|popup|dialog)/i.test(container); } @@ -483,12 +505,12 @@ function scoreCandidate(candidate: InteractionCandidate): number { return score; } -function isShellLocator(locator: string | null | undefined): boolean { +function isPageShellContainer(locator: string | null | undefined): boolean { if (!locator) { return false; } - return /(nav\[role="navigation"\]|header|menu|breadcrumb|footer)/i.test(locator); + return /(^|[\s>+~,.#\[])(nav|navigation|mainnav|header|menu|breadcrumb|footer)([\s>+~,.#\]_-]|$)/i.test(locator); } function collectNavigationLabels(sections: ReturnType): Set { @@ -515,6 +537,24 @@ function normalizeCandidateLabel(label: string): string { return label.trim().toLowerCase(); } +function isDestructiveAction(element: ResearchElement, config: DocbotConfig): boolean { + const label = normalizeCandidateLabel(element.name); + const deniedLabels = config.docs?.deniedActionLabels || DEFAULT_DENIED_ACTION_LABELS; + if (deniedLabels.some((denied) => label.includes(normalizeCandidateLabel(denied)))) { + return true; + } + + const locator = `${element.css || ''} ${element.xpath || ''}`.toLowerCase(); + return deniedLabels.some((denied) => locator.includes(normalizeCandidateLabel(denied))); +} + +function getPositiveConfigNumber(value: number | undefined, fallback: number): number { + if (!value || value <= 0) { + return fallback; + } + return value; +} + function limitInlineText(text: string, maxLength: number): string { const normalized = text.replace(/\s+/g, ' ').trim(); if (normalized.length <= maxLength) { diff --git a/boat/doc-collector/src/cli.ts b/boat/doc-collector/src/cli.ts index 1f9a71b..3f953f7 100644 --- a/boat/doc-collector/src/cli.ts +++ b/boat/doc-collector/src/cli.ts @@ -101,6 +101,9 @@ export function createDocsCommands(name = 'docs'): Command { includePaths: [], excludePaths: [], deniedPathSegments: ['callback', 'callbacks', 'logout', 'signout', 'sign_out', 'destroy', 'delete', 'remove'], + deniedActionLabels: ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'], + maxPrimaryCandidates: 3, + maxInteractions: 5, minCanActions: 1, minInteractiveElements: 3, // prompt: 'Add domain-specific documentation guidance here', diff --git a/boat/doc-collector/src/config.ts b/boat/doc-collector/src/config.ts index 42eed72..8b03a0e 100644 --- a/boat/doc-collector/src/config.ts +++ b/boat/doc-collector/src/config.ts @@ -120,6 +120,9 @@ class DocbotConfigParser { includePaths: [], excludePaths: [], deniedPathSegments: ['callback', 'callbacks', 'logout', 'signout', 'sign_out', 'destroy', 'delete', 'remove'], + deniedActionLabels: ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'], + maxPrimaryCandidates: 3, + maxInteractions: 5, minCanActions: 1, minInteractiveElements: 3, }, @@ -154,6 +157,9 @@ interface DocbotConfig { includePaths?: string[]; excludePaths?: string[]; deniedPathSegments?: string[]; + deniedActionLabels?: string[]; + maxPrimaryCandidates?: number; + maxInteractions?: number; minCanActions?: number; minInteractiveElements?: number; interactive?: boolean; diff --git a/docs/doc-collector.md b/docs/doc-collector.md index 05283f8..2c59e1b 100644 --- a/docs/doc-collector.md +++ b/docs/doc-collector.md @@ -126,6 +126,9 @@ export default { includePaths: [], excludePaths: [], deniedPathSegments: ['callback', 'callbacks', 'logout', 'signout', 'sign_out', 'destroy', 'delete', 'remove'], + deniedActionLabels: ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'], + maxPrimaryCandidates: 3, + maxInteractions: 5, minCanActions: 1, minInteractiveElements: 3, interactive: false, @@ -145,6 +148,9 @@ export default { | `includePaths` | `[]` | Only allow matching paths | | `excludePaths` | `[]` | Exclude matching paths | | `deniedPathSegments` | built-in list | Block terminal or destructive endpoints | +| `deniedActionLabels` | built-in list | Skip interactive candidates whose label or locator looks destructive | +| `maxPrimaryCandidates` | `3` | Maximum non-tab interaction candidates selected from page content/control sections | +| `maxInteractions` | `5` | Maximum deterministic interactions attempted per page | | `minCanActions` | `1` | Minimum proven actions before a page is considered low-signal | | `minInteractiveElements` | `3` | Minimum interactive elements before a page is considered low-signal | diff --git a/tests/unit/doc-collector.test.ts b/tests/unit/doc-collector.test.ts index 4558309..f456d62 100644 --- a/tests/unit/doc-collector.test.ts +++ b/tests/unit/doc-collector.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'bun:test'; +import { z } from 'zod'; import { Documentarian } from '../../boat/doc-collector/src/ai/documentarian.ts'; import { pickDocActionCandidates } from '../../boat/doc-collector/src/ai/tools.ts'; import { DocBot } from '../../boat/doc-collector/src/docbot.ts'; @@ -233,9 +234,102 @@ describe('doc-collector interactive candidate selection', () => { expect(pickDocActionCandidates(research)).toEqual([]); }); + + it('keeps content control candidates inside sticky header containers', () => { + const research = ` +## Content Filters Controls + +> Container: '.sticky-header .first' + +| Element | Type | ARIA | CSS | +|------|------|------|------| +| 'Automated' | link | { role: 'link', text: 'Automated' } | 'a.filter-tab' | +| 'Unfinished' | link | { role: 'link', text: 'Unfinished' } | 'a.filter-tab' | + +## Control Create New Branch + +> Container: '.flex-none.black' + +| Element | Type | ARIA | CSS | +|------|------|------|------| +| 'Create New Branch' | button | { role: 'button', text: 'Create New Branch' } | 'button.primary-btn' | +`; + + expect(pickDocActionCandidates(research).map((candidate) => candidate.label)).toEqual(['Automated', 'Unfinished', 'Create New Branch']); + }); + + it('keeps navigation and destructive actions out of interactive candidates', () => { + const research = ` +## Navigation + +> Container: '.mainnav-menu' + +| Element | Type | ARIA | CSS | +|------|------|------|------| +| 'Branches' | link | { role: 'link', text: 'Branches' } | 'a[href="/branches"]' | + +## Content + +| Element | Type | ARIA | CSS | +|------|------|------|------| +| 'View Branch' | link | { role: 'link', text: 'View Branch' } | 'a.branch' | +| 'Delete Branch' | button | { role: 'button', text: 'Delete Branch' } | 'button.delete' | +| 'Archive Branch' | button | { role: 'button', text: 'Archive Branch' } | 'button.archive' | +`; + + expect(pickDocActionCandidates(research)).toEqual([{ label: 'View Branch', role: 'link', section: 'Content' }]); + }); + + it('allows candidate limits to be configured', () => { + const research = ` +## Content + +| Element | Type | ARIA | CSS | +|------|------|------|------| +| 'Item A' | link | { role: 'link', text: 'Item A' } | 'a.item-a' | +| 'Item B' | link | { role: 'link', text: 'Item B' } | 'a.item-b' | +| 'Item C' | link | { role: 'link', text: 'Item C' } | 'a.item-c' | +| 'Item D' | link | { role: 'link', text: 'Item D' } | 'a.item-d' | +`; + + expect(pickDocActionCandidates(research, { docs: { maxPrimaryCandidates: 4 } })).toHaveLength(4); + }); }); describe('documentarian fallback', () => { + it('uses strict-compatible schema for interaction element metadata', async () => { + const provider = { + async generateObject(_messages: Array<{ role: string; content: string }>, schema: any) { + const jsonSchema = z.toJSONSchema(schema) as any; + const interaction = jsonSchema.properties.interactions.anyOf[0].items; + const element = interaction.properties.element.anyOf[0]; + + expect(interaction.required).toContain('element'); + expect(element.required).toEqual(['role', 'name', 'section', 'container', 'locator']); + + return { + object: { + summary: 'Static page', + can: [], + might: [], + interactions: null, + }, + }; + }, + } as any; + + const documentarian = new Documentarian(provider, {}); + const result = await documentarian.document( + { + url: '/test', + title: 'Test', + }, + '## Content\nStatic research' + ); + + expect(result.interactions).toBeUndefined(); + }); + it('retries with sanitized research after JSON generation failure', async () => { const calls: string[] = []; const provider = {