From d7f2c5c126293a3864a8e9028c730e3f2cf0bd24 Mon Sep 17 00:00:00 2001 From: Denys Kuchma Date: Wed, 27 May 2026 20:24:49 +0300 Subject: [PATCH 1/2] add interactive mode --- boat/doc-collector/src/ai/documentarian.ts | 457 +++++++++--- boat/doc-collector/src/ai/tools.ts | 766 +++++++++++++++++++++ boat/doc-collector/src/cli.ts | 1 + boat/doc-collector/src/config.ts | 2 + boat/doc-collector/src/docbot.ts | 69 +- boat/doc-collector/src/docs-renderer.ts | 58 +- docs/doc-collector.md | 77 ++- tests/unit/doc-collector.test.ts | 610 ++++++++++++++++ 8 files changed, 1945 insertions(+), 95 deletions(-) create mode 100644 boat/doc-collector/src/ai/tools.ts diff --git a/boat/doc-collector/src/ai/documentarian.ts b/boat/doc-collector/src/ai/documentarian.ts index 8396d3a..8dd8432 100644 --- a/boat/doc-collector/src/ai/documentarian.ts +++ b/boat/doc-collector/src/ai/documentarian.ts @@ -1,19 +1,35 @@ import dedent from 'dedent'; import { z } from 'zod'; import type { AIProvider } from '../../../../src/ai/provider.ts'; +import type Explorer from '../../../../src/explorer.ts'; import type { WebPageState } from '../../../../src/state-manager.ts'; +import { tag } from '../../../../src/utils/logger.ts'; import type { DocbotConfig } from '../config.ts'; +import { collectDocInteractions } from './tools.ts'; class Documentarian { private provider: AIProvider; private config: DocbotConfig; + private explorer?: Explorer; - constructor(provider: AIProvider, config: DocbotConfig = {}) { + constructor(provider: AIProvider, config: DocbotConfig = {}, explorer?: Explorer) { this.provider = provider; this.config = config; + this.explorer = explorer; } async document(state: WebPageState, research: string): Promise { + const interactiveEnabled = this.config.docs?.interactive === true && this.explorer; + if (!interactiveEnabled) { + tag('info').log('Documentarian: Using static mode (interactive disabled or no explorer)'); + return this.documentStatic(state, research); + } + + tag('info').log('Documentarian: Using interactive mode with tools'); + return this.documentWithInteraction(state, research); + } + + private async documentStatic(state: WebPageState, research: string): Promise { try { return await this.generateDocumentation(state, research); } catch (error) { @@ -25,40 +41,135 @@ class Documentarian { } } + private async documentWithInteraction(state: WebPageState, research: string): Promise { + try { + tag('info').log('Starting interactive exploration...'); + + const deterministicInteractions = await collectDocInteractions(this.explorer!, state, research); + const meaningfulInteractions = this.getMeaningfulInteractions(deterministicInteractions); + if (meaningfulInteractions.length > 0) { + tag('success').log(`Collected ${meaningfulInteractions.length} deterministic interactions`); + return await this.generateDocumentationWithInteractions(state, research, meaningfulInteractions); + } + + if (deterministicInteractions.length > 0) { + tag('info').log('Interactive exploration found only low-value navigation changes. Using static documentation.'); + } else { + tag('info').log('Interactive exploration found no reliable deterministic interactions. Using static documentation.'); + } + + return this.documentStatic(state, research); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + tag('warning').log(`Interactive documentation failed: ${message}. Falling back to static.`); + return this.documentStatic(state, research); + } + } + + private getMeaningfulInteractions(interactions: StateTransition[]): StateTransition[] { + return interactions.filter((interaction) => { + const action = interaction.action || ''; + if (action.startsWith('Opened detail page:')) { + return true; + } + if (action.startsWith('Opened pagination page:')) { + return true; + } + if (action.startsWith('Switched to tab:')) { + return true; + } + if (action.startsWith('Activated button:')) { + return true; + } + if (action.startsWith('Opened category page:')) { + return false; + } + if (action.startsWith('I.click(')) { + return false; + } + + return Boolean(interaction.targetUrl); + }); + } + + private async generateDocumentationWithInteractions(state: WebPageState, research: string, interactions: StateTransition[]): Promise { + const messages = [ + { + role: 'system' as const, + content: this.getSystemPrompt(), + }, + { + role: 'user' as const, + content: this.buildPrompt(state, `${research}${this.buildInteractionContext(interactions)}`), + }, + ]; + + const response = await this.provider.generateObject(messages, pageDocumentationSchema, undefined, { + agentName: 'documentarian', + }); + + return this.normalizeDocumentation( + { + ...(response.object as PageDocumentation), + interactions, + }, + state, + research + ); + } + + private async generateDocumentation(state: WebPageState, research: string, simplified = false): Promise { + const messages = [ + { + role: 'system' as const, + content: this.getSystemPrompt(), + }, + { + role: 'user' as const, + content: this.buildPrompt(state, research, simplified), + }, + ]; + + const response = await this.provider.generateObject(messages, pageDocumentationSchema, undefined, { + agentName: 'documentarian', + }); + + return this.normalizeDocumentation(response.object as PageDocumentation, state, research); + } + private getSystemPrompt(): string { - const customPrompt = this.config.docs?.prompt; let promptSuffix = ''; - if (customPrompt) { - promptSuffix = customPrompt; + if (this.config.docs?.prompt) { + promptSuffix = this.config.docs.prompt; } return dedent` - - You are a product analyst preparing functional website documentation from UI research. - - - - Convert exploratory UI research into a precise spec of what users can do on the current page. - Distinguish proven capabilities from assumptions. - Prefer accuracy over coverage. - - - - Only list capabilities that are grounded in the provided page research. - Put actions into "can" only when there is direct evidence in the page context. - Put actions into "might" only when the UI strongly suggests a capability but proof is incomplete. - Describe each action from the end-user perspective. - Be explicit about scope: - - one item - - list of items - - bulk operations - - all items - - page-level - Avoid implementation details, selectors, and QA wording. - Avoid duplicate actions with different phrasing. - - - ${promptSuffix} + + You are a product analyst preparing functional website documentation from UI research. + + + + Convert exploratory UI research into a precise spec of what users can do on the current page. + Distinguish proven capabilities from assumptions. + Prefer accuracy over coverage. + + + + Only list capabilities that are grounded in the provided page research. + Put actions into "can" only when there is direct evidence in the page context. + Put actions into "might" only when the UI strongly suggests a capability but proof is incomplete. + Describe each action from the end-user perspective. + Be explicit about scope: + - one item + - list of items + - bulk operations + - all items + - page-level + Avoid implementation details, selectors, and QA wording. + Avoid duplicate actions with different phrasing. + + + ${promptSuffix} `; } @@ -68,80 +179,200 @@ class Documentarian { .slice(0, 50) .map((link) => `- ${link.title}: ${link.url}`) .join('\n'); - const simplificationNote = simplified - ? dedent` + + let simplificationNote = ''; + if (simplified) { + simplificationNote = dedent` The research text was simplified because the original formatting was noisy. Ignore malformed table syntax and rely only on clear, repeated signals. Prefer fewer actions over speculative coverage. - ` - : ''; + `; + } return dedent` - - URL: ${state.url} - Title: ${state.title || ''} - Headings: ${headings} - - - - ${links} - - - - ${research} - - - ${simplificationNote} - - - Return structured data. - summary: short page purpose statement. - can: actions you are 100% sure are available on page. - might: actions that look possible but are not fully proven. - For each action provide: - - action: concise user-facing capability phrased as "user can ..." - - scope: one of one item, list of items, bulk operations, all items, page-level - - evidence: short reason based on visible UI or research - + + URL: ${state.url} + Title: ${state.title || ''} + Headings: ${headings} + + + + ${links} + + + + ${research} + + + ${simplificationNote} + + + Return structured data. + summary: short page purpose statement. + can: actions you are 100% sure are available on page. + might: actions that look possible but are not fully proven. + For each action provide: + - action: concise user-facing capability phrased as "user can ..." + - scope: one of one item, list of items, bulk operations, all items, page-level + - evidence: short reason based on visible UI or research + `; } - private async generateDocumentation(state: WebPageState, research: string, simplified = false): Promise { - const messages = [ - { - role: 'system' as const, - content: this.getSystemPrompt(), - }, + private buildInteractionContext(interactions: StateTransition[]): string { + const lines = interactions.map((interaction) => `- ${interaction.action}: ${interaction.before} -> ${interaction.after}`).join('\n'); + return `\n\n\nThe following interactions were performed:\n${lines}\n`; + } + + private shouldRetryWithSanitizedResearch(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes('Failed to generate JSON') || message.includes('Failed to validate JSON') || message.includes('failed_generation') || message.includes('No object generated') || message.includes('response did not match schema'); + } + + private normalizeDocumentation(documentation: PageDocumentation, state: WebPageState, research: string): PageDocumentation { + const can = this.compactShellActions(documentation.can, research); + const might = this.filterWeakMightActions(documentation.might, research, state); + const qualityNotes = this.evaluateDocumentationQuality( { - role: 'user' as const, - content: this.buildPrompt(state, research, simplified), + ...documentation, + can, + might, }, - ]; + state, + research + ); - const response = await this.provider.generateObject(messages, pageDocumentationSchema, undefined, { - agentName: 'documentarian', + return { + ...documentation, + can, + might, + qualityNotes, + }; + } + + private compactShellActions(can: Capability[], research: string): Capability[] { + const shellActions = can.filter((item) => this.isShellNavigationAction(item)); + if (shellActions.length < 3) { + return can; + } + + const compacted: Capability[] = []; + const preserved = can.filter((item) => { + if (this.isShellNavigationAction(item)) { + return false; + } + if (this.isSearchAction(item.action)) { + return false; + } + if (this.isPaginationAction(item.action)) { + return false; + } + return true; }); + const hasSearch = can.some((item) => this.isSearchAction(item.action)); + const hasPagination = can.some((item) => this.isPaginationAction(item.action)); + const hasAccount = can.some((item) => this.isAccountAction(item.action)); + const hasSectionNavigation = can.some((item) => this.isSectionNavigationAction(item.action)); + const hasExternalNavigation = can.some((item) => this.isExternalLinkAction(item.action)); + const hasUtilityNavigation = can.some((item) => this.isUtilityAction(item.action)); + + if (hasSectionNavigation) { + compacted.push({ + action: 'user can navigate to major site sections using the visible navigation links', + scope: 'page-level', + evidence: this.hasMenuSection(research) ? 'Multiple section links are visible in the page header/menu.' : 'Multiple section links are visible on the page.', + }); + } - return response.object as PageDocumentation; + if (hasAccount) { + compacted.push({ + action: 'user can access account-related pages from the visible header links', + scope: 'page-level', + evidence: 'Account-related links such as login or personal lists are visible in the page navigation.', + }); + } + + if (hasSearch) { + const searchAction = can.find((item) => this.isSearchAction(item.action)); + if (searchAction) { + compacted.push(searchAction); + } + } + + if (hasPagination) { + const paginationAction = can.find((item) => this.isPaginationAction(item.action)); + if (paginationAction) { + compacted.push(paginationAction); + } + } + + if (hasExternalNavigation) { + compacted.push({ + action: 'user can open external links shown on the page', + scope: 'page-level', + evidence: 'External destination links are visible in the page content or footer.', + }); + } + + if (hasUtilityNavigation) { + compacted.push({ + action: 'user can open utility or support pages linked from the site navigation', + scope: 'page-level', + evidence: 'Utility links such as feedback, help, or related support pages are visible in navigation.', + }); + } + + return [...compacted, ...preserved]; } - private shouldRetryWithSanitizedResearch(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return message.includes('Failed to generate JSON') || message.includes('failed_generation'); + private filterWeakMightActions(might: Capability[], research: string, state: WebPageState): Capability[] { + return might.filter((item) => { + const action = item.action.toLowerCase(); + const evidence = item.evidence.toLowerCase(); + + if (/(add .*personal list|add .*favorites|add-to-list)/i.test(action) && !/(favorite|wishlist|bookmark|save|add)/i.test(research)) { + return false; + } + + if (/(typical|suggests functionality|suggests a personalized list page)/i.test(evidence)) { + if (!this.hasPrimaryContentEvidence(research) && !this.looksLikeItemAction(action, state.url)) { + return false; + } + } + + return true; + }); + } + + private evaluateDocumentationQuality(documentation: PageDocumentation, state: WebPageState, research: string): string[] { + const notes: string[] = []; + const allPageLevel = documentation.can.length > 0 && documentation.can.every((item) => item.scope === 'page-level'); + const hasItemLevel = [...documentation.can, ...documentation.might].some((item) => item.scope === 'one item' || item.scope === 'list of items'); + const contentEvidence = this.hasPrimaryContentEvidence(research); + + if (allPageLevel && !hasItemLevel && /(films|movies|catalog|list|series|cartoons)/i.test(state.url)) { + notes.push('Coverage is currently limited to page-level navigation and search actions; item-level content interactions were not confirmed.'); + } + if (!contentEvidence) { + notes.push('Research did not provide a dedicated content section, so content-specific behavior may be under-documented.'); + } + if ((documentation.interactions || []).length === 0 && this.config.docs?.interactive) { + notes.push('Interactive exploration did not produce any reliable page-specific transitions for this page.'); + } + + return notes; } private sanitizeResearch(research: string): string { - const lines = research.split('\n'); const sanitized: string[] = []; - for (const line of lines) { + for (const line of research.split('\n')) { if (!line.trim()) { sanitized.push(line); continue; } - if (!line.includes('|')) { sanitized.push(line); continue; @@ -151,12 +382,10 @@ class Documentarian { if (pipeCount < 2) { continue; } - if (line.includes('|------')) { sanitized.push(line); continue; } - if (line.trim().startsWith('|') && pipeCount >= 4) { sanitized.push(line); } @@ -164,6 +393,55 @@ class Documentarian { return sanitized.join('\n'); } + + private isShellNavigationAction(item: Capability): boolean { + if (item.scope !== 'page-level') { + return false; + } + + const action = item.action.toLowerCase(); + return this.isSectionNavigationAction(action) || this.isAccountAction(action) || this.isExternalLinkAction(action) || this.isUtilityAction(action); + } + + private isSectionNavigationAction(action: string): boolean { + if (this.isPaginationAction(action) || this.isSearchAction(action) || this.isExternalLinkAction(action)) { + return false; + } + + return /(navigate to .*page|navigate to .*category|navigate to .*section|click the .* link to navigate)/i.test(action); + } + + private isAccountAction(action: string): boolean { + return /(login|log in|personal lists|my lists|account)/i.test(action); + } + + private isSearchAction(action: string): boolean { + return /search|search textbox|search button/.test(action.toLowerCase()); + } + + private isPaginationAction(action: string): boolean { + return /pagination|navigate between pages|page \d+/.test(action.toLowerCase()); + } + + private isExternalLinkAction(action: string): boolean { + return /external /.test(action.toLowerCase()); + } + + private isUtilityAction(action: string): boolean { + return /(feedback|support|help|contact|abuse|report)/i.test(action); + } + + private hasMenuSection(research: string): boolean { + return /##\s+(menu|navigation|header)/i.test(research); + } + + private hasPrimaryContentEvidence(research: string): boolean { + return /##\s+(content|cards|results|grid|catalog)/i.test(research); + } + + private looksLikeItemAction(action: string, url: string): boolean { + return /(detail page|individual .* item|film item|movie item|view details)/i.test(action) || /(films|movies|catalog|list)/i.test(url); + } } const capabilitySchema = z.object({ @@ -172,13 +450,28 @@ const capabilitySchema = z.object({ evidence: z.string(), }); +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(), +}); + const pageDocumentationSchema = z.object({ summary: z.string(), can: z.array(capabilitySchema), might: z.array(capabilitySchema), + interactions: z.array(stateTransitionSchema).optional(), }); -type PageDocumentation = z.infer; +type Capability = z.infer; +type StateTransition = z.infer; +type PageDocumentation = z.infer & { + interactions?: StateTransition[]; + qualityNotes?: string[]; +}; export { Documentarian }; -export type { PageDocumentation }; +export type { PageDocumentation, StateTransition }; diff --git a/boat/doc-collector/src/ai/tools.ts b/boat/doc-collector/src/ai/tools.ts new file mode 100644 index 0000000..e25f9e3 --- /dev/null +++ b/boat/doc-collector/src/ai/tools.ts @@ -0,0 +1,766 @@ +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'; + +export interface DocStateTransition { + action: string; + before: string; + after: string; + targetUrl?: string; + discoveredUrls?: string[]; + newCapabilities?: string[]; +} + +interface InteractionCandidate { + element: ResearchElement; + container?: string; + kind: 'detail' | 'category' | 'account' | 'button' | 'pagination' | 'tab'; + sectionName: string; +} + +const MAX_PRIMARY_CANDIDATES = 3; +const MAX_INTERACTIONS = 5; +const MAX_LINKS = 15; +const DEFAULT_WAIT_MS = 700; +const TAB_WAIT_MS = 500; + +const CATEGORY_LABELS = new Set(['серіали', 'мультсеріали', 'фільми', 'мультфільми', 'добірки', 'аніме', 'дорами', 'collections', 'series', 'cartoons', 'films', 'anime', 'dorama']); + +const ACCOUNT_LABELS = new Set(['мої списки', 'вхід', 'login', 'sign in', 'account', 'my lists', 'personal lists']); + +const SEARCH_LABELS = new Set(['пошук...', 'search', 'search box', 'search button']); + +export async function collectDocInteractions(explorer: Explorer, state: WebPageState, research: string): Promise { + const sections = parseResearchSections(research); + const transitions: DocStateTransition[] = []; + const tabGroup = findTabGroup(sections); + + if (tabGroup) { + transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url))); + } + + for (const candidate of findActionCandidates(sections)) { + if (transitions.length >= MAX_INTERACTIONS - 1) { + break; + } + + const transition = await executeInteraction(explorer, candidate, state.url, DEFAULT_WAIT_MS); + if (!transition) { + continue; + } + + transitions.push(transition); + } + + const paginationCandidate = findPaginationCandidate(sections); + if (paginationCandidate && transitions.length < MAX_INTERACTIONS) { + const transition = await executeInteraction(explorer, paginationCandidate, state.url, DEFAULT_WAIT_MS); + if (transition) { + transitions.push(transition); + } + } + + return transitions; +} + +export function pickDocActionCandidates(research: string): Array<{ label: string; kind: InteractionCandidate['kind']; section: string }> { + return findActionCandidates(parseResearchSections(research)).map((candidate) => ({ + label: candidate.element.name.trim(), + kind: candidate.kind, + section: candidate.sectionName, + })); +} + +async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: ResearchElement[]; container?: string }, restoreUrl: string): Promise { + const transitions: DocStateTransition[] = []; + + for (const element of tabGroup.elements) { + const transition = await executeInteraction( + explorer, + { + element, + container: tabGroup.container, + kind: 'tab', + sectionName: 'tab', + }, + restoreUrl, + TAB_WAIT_MS + ); + if (!transition) { + continue; + } + + transitions.push(transition); + } + + await restoreInteractionState(explorer, restoreUrl, buildPrimaryCommand(tabGroup.elements[0], tabGroup.container)); + return transitions; +} + +async function executeInteraction(explorer: Explorer, candidate: InteractionCandidate, restoreUrl: string, waitMs: number): Promise { + const beforeState = explorer.getStateManager().getCurrentState(); + if (!beforeState) { + return null; + } + + const executed = await attemptInteraction(explorer, candidate); + if (!executed) { + return null; + } + + await wait(waitMs); + + const afterState = explorer.getStateManager().getCurrentState(); + if (!afterState) { + return null; + } + + const urlChanged = beforeState.url !== afterState.url; + const newElements = countAriaChanges(beforeState.ariaSnapshot || '', afterState.ariaSnapshot || '').newCount; + const transition = buildTransition(candidate, beforeState, afterState, urlChanged, newElements); + + if (urlChanged) { + await restoreInteractionState(explorer, restoreUrl); + } + + return transition; +} + +async function attemptInteraction(explorer: Explorer, candidate: InteractionCandidate): Promise { + const action = explorer.createAction(); + + for (const command of buildClickCommands(candidate.element, candidate.container)) { + const success = await action.attempt(command, buildPurpose(candidate), false); + if (success) { + return true; + } + } + + return false; +} + +async function restoreInteractionState(explorer: Explorer, restoreUrl: string, primaryCommand?: string | null): Promise { + if (primaryCommand) { + const action = explorer.createAction(); + const restored = await action.attempt(primaryCommand, `Restore initial state on ${restoreUrl}`, false); + if (restored) { + await wait(TAB_WAIT_MS); + return; + } + } + + const action = explorer.createAction(); + await action.attempt(`I.amOnPage(${JSON.stringify(restoreUrl)})`, `Restore page ${restoreUrl}`, false); +} + +function buildTransition(candidate: InteractionCandidate, beforeState: WebPageState, afterState: WebPageState, urlChanged: boolean, newElements: number): DocStateTransition { + const transition: DocStateTransition = { + action: describeAction(candidate, urlChanged), + before: summarizeAria(beforeState.ariaSnapshot || ''), + after: summarizeInteractiveState(candidate.kind === 'tab' ? 'Tab content' : 'After', afterState), + discoveredUrls: collectLinks(afterState).map((link) => link.url), + newCapabilities: collectCapabilities(afterState, urlChanged, newElements), + }; + + if (urlChanged) { + transition.targetUrl = afterState.url; + } + + return transition; +} + +function collectCapabilities(state: WebPageState, urlChanged: boolean, newElements: number): string[] { + const capabilities = collectDiscoveryNotes(state); + if (urlChanged) { + return prependUnique('Navigated to new page', capabilities); + } + if (newElements > 0) { + return prependUnique(`Discovered ${newElements} new elements`, capabilities); + } + + return capabilities; +} + +function findTabGroup(sections: ReturnType): { elements: ResearchElement[]; container?: string } | null { + for (const section of sections) { + const sectionName = section.name.toLowerCase(); + const container = section.containerCss?.toLowerCase() || ''; + if (/(overlay|modal|popup|dialog)/i.test(sectionName) || /(overlay|modal|popup|dialog)/i.test(container)) { + continue; + } + + const elements = section.elements.filter((element) => isTabCandidate(element)); + if (elements.length < 2 || elements.length > 6) { + continue; + } + + if (section.containerCss) { + return { elements, container: section.containerCss }; + } + + return { elements }; + } + + return null; +} + +function findActionCandidates(sections: ReturnType): InteractionCandidate[] { + const candidates: InteractionCandidate[] = []; + const seen = new Set(); + const blockedLabels = collectNavigationLabels(sections); + + for (const section of sections) { + const sectionName = section.name.toLowerCase(); + if (isNavigationSection(sectionName)) { + continue; + } + if (isShellLikeListSection(section, blockedLabels)) { + continue; + } + + for (const element of section.elements) { + const candidate = toInteractionCandidate(element, sectionName, section.containerCss, blockedLabels); + if (!candidate) { + continue; + } + + const key = `${candidate.kind}:${normalizeCandidateLabel(candidate.element.name)}`; + if (seen.has(key)) { + continue; + } + + seen.add(key); + candidates.push(candidate); + } + } + + return candidates.sort((a, b) => scoreCandidate(b) - scoreCandidate(a)).slice(0, MAX_PRIMARY_CANDIDATES); +} + +function toInteractionCandidate(element: ResearchElement, sectionName: string, container: string | null | undefined, blockedLabels: Set): InteractionCandidate | null { + const role = getElementRole(element); + if (!role || role === 'textbox') { + return null; + } + if (isShellLocator(element.css) || isShellLocator(element.xpath) || isShellLocator(container)) { + return null; + } + + if (role === 'link') { + if (!isSafeContentLink(element)) { + return null; + } + + const kind = getLinkKind(element, sectionName); + if (kind !== 'detail') { + return null; + } + if (blockedLabels.has(normalizeCandidateLabel(element.name))) { + return null; + } + + if (container) { + return { element, container, kind, sectionName }; + } + + return { element, kind, sectionName }; + } + + if (role !== 'button' || !isInterestingButton(element.name)) { + return null; + } + + if (container) { + return { element, container, kind: 'button', sectionName }; + } + + return { element, kind: 'button', sectionName }; +} + +function findPaginationCandidate(sections: ReturnType): InteractionCandidate | null { + for (const section of sections) { + if (!section.name.toLowerCase().includes('navigation')) { + continue; + } + + const pages = section.elements.filter((element) => getElementRole(element) === 'link' && /^\d+$/.test(element.name.trim())); + if (pages.length < 2) { + continue; + } + + const target = pages.find((element) => element.name.trim() !== '7') || pages[0]; + if (section.containerCss) { + return { + element: target, + container: section.containerCss, + kind: 'pagination', + sectionName: section.name.toLowerCase(), + }; + } + + return { + element: target, + kind: 'pagination', + sectionName: section.name.toLowerCase(), + }; + } + + return null; +} + +function buildClickCommands(element: ResearchElement, container?: string): string[] { + const commands: string[] = []; + + if (element.css) { + if (container && !element.css.startsWith(container)) { + commands.push(`I.click(${JSON.stringify(element.css)}, ${JSON.stringify(container)})`); + } + commands.push(`I.click(${JSON.stringify(element.css)})`); + } + + if (element.aria) { + if (container) { + commands.push(`I.click(${JSON.stringify(element.aria)}, ${JSON.stringify(container)})`); + } + commands.push(`I.click(${JSON.stringify(element.aria)})`); + } + + const xpath = buildXPathLocator(element); + if (xpath) { + commands.push(`I.click(${JSON.stringify(xpath)})`); + } + + return [...new Set(commands)]; +} + +function buildPrimaryCommand(element: ResearchElement, container?: string): string | null { + return buildClickCommands(element, container)[0] || null; +} + +function buildXPathLocator(element: ResearchElement): string | null { + if (!element.name) { + return null; + } + + const text = xpathStringLiteral(element.name.trim()); + const role = getElementRole(element); + if (role === 'link') { + return `//a[normalize-space()=${text}]`; + } + if (role === 'button' || role === 'tab') { + return `//*[self::button or @role="button" or @role="tab"][normalize-space()=${text}]`; + } + + return `//*[normalize-space()=${text}]`; +} + +function buildPurpose(candidate: InteractionCandidate): string { + const label = candidate.element.name.trim(); + + if (candidate.kind === 'detail') { + return `Open content detail page for ${label}`; + } + if (candidate.kind === 'category') { + return `Open category page for ${label}`; + } + if (candidate.kind === 'account') { + return `Open account page for ${label}`; + } + if (candidate.kind === 'pagination') { + return `Open pagination page ${label}`; + } + if (candidate.kind === 'button') { + return `Check button behavior for ${label}`; + } + if (candidate.kind === 'tab') { + return `Explore tab ${label}`; + } + + return `Inspect interaction for ${label}`; +} + +function summarizeAria(aria: string): string { + const lines = aria.split('\n').filter((line) => line.trim()); + if (lines.length === 0) { + return 'No elements'; + } + + const roleCounts: Record = {}; + for (const role of lines.map(extractAriaRole).filter((role): role is string => Boolean(role))) { + roleCounts[role] = (roleCounts[role] || 0) + 1; + } + + const topRoles = Object.entries(roleCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([role, count]) => `${role}:${count}`) + .join(', '); + + return `${lines.length} elements (${topRoles})`; +} + +function extractAriaRole(line: string): string | null { + const roleMatch = line.match(/\[role: ([\w-]+)\]/); + if (roleMatch) { + return roleMatch[1]; + } + + const yamlMatch = line.trim().match(/^- ([\w-]+)(?:\s|$|:)/); + if (yamlMatch) { + return yamlMatch[1]; + } + + return null; +} + +function countAriaChanges(before: string, after: string): { newCount: number; removedCount: number } { + const beforeLines = new Set(before.split('\n').filter((line) => line.trim())); + const afterLines = new Set(after.split('\n').filter((line) => line.trim())); + let newCount = 0; + let removedCount = 0; + + for (const line of afterLines) { + if (!beforeLines.has(line)) { + newCount++; + } + } + + for (const line of beforeLines) { + if (!afterLines.has(line)) { + removedCount++; + } + } + + return { newCount, removedCount }; +} + +function summarizeInteractiveState(prefix: string, state: WebPageState): string { + const parts = [summarizeAria(state.ariaSnapshot || '')]; + const headings = collectHeadings(state).slice(0, 3); + const links = collectLinks(state).slice(0, 3); + + if (state.url) { + parts.push(`URL ${state.url}`); + } + if (headings.length > 0) { + parts.push(`Headings: ${headings.join(' | ')}`); + } + if (links.length > 0) { + parts.push(`Links: ${links.map((link) => `${link.title} -> ${link.url}`).join('; ')}`); + } + + return `${prefix}: ${parts.join('. ')}`; +} + +function collectDiscoveryNotes(state: WebPageState): string[] { + const notes: string[] = []; + const headings = collectHeadings(state); + const links = collectLinks(state); + + if (headings.length > 0) { + notes.push(`Revealed headings: ${headings.slice(0, 3).join(' | ')}`); + } + if (links.length > 0) { + notes.push(`Revealed ${Math.min(links.length, MAX_LINKS)} links`); + } + + return notes; +} + +function collectHeadings(state: { h1?: string; h2?: string; h3?: string; h4?: string }): string[] { + return [state.h1, state.h2, state.h3, state.h4].filter((heading): heading is string => Boolean(heading)).map((heading) => heading.trim()); +} + +function collectLinks(state: { links?: Array<{ title: string; url: string }> }): Array<{ title: string; url: string }> { + return (state.links || []) + .filter((link) => link.url) + .slice(0, MAX_LINKS) + .map((link) => ({ + title: link.title || link.url, + url: link.url, + })); +} + +function prependUnique(first: string, rest: string[]): string[] { + const result = [first]; + for (const item of rest) { + if (result.includes(item)) { + continue; + } + result.push(item); + } + + return result; +} + +function describeAction(candidate: InteractionCandidate, urlChanged: boolean): string { + const role = getElementRole(candidate.element); + const label = candidate.element.name.trim(); + + if (/^\d+$/.test(label)) { + return `Opened pagination page: ${label}`; + } + if (candidate.kind === 'account' && urlChanged) { + return `Opened account page: ${label}`; + } + if (candidate.kind === 'category' && urlChanged) { + return `Opened category page: ${label}`; + } + if (role === 'button') { + return `Activated button: ${label}`; + } + if (role === 'tab' || candidate.kind === 'tab') { + return `Switched to tab: ${label}`; + } + if (role === 'link' && urlChanged) { + return `Opened detail page: ${label}`; + } + + return `Interacted with: ${label}`; +} + +function isTabCandidate(element: ResearchElement): boolean { + const role = getElementRole(element); + if (role === 'tab') { + return true; + } + if (role !== 'button') { + return false; + } + if (!element.name || element.name.length > 24) { + return false; + } + if (/(subscribe|yes|no|close|дякую|thanks)/i.test(element.name)) { + return false; + } + + return true; +} + +function isSafeContentLink(element: ResearchElement): boolean { + const name = element.name.trim(); + if (!name || /^\d+$/.test(name) || name.length < 3) { + return false; + } + + const lower = normalizeCandidateLabel(name); + if (['uaserials', 'login', 'home'].includes(lower)) { + return false; + } + + return true; +} + +function isInterestingButton(name: string): boolean { + const lower = normalizeCandidateLabel(name); + if (!lower) { + return false; + } + + return /(save|launch|submit|create|run|search|filter|sort|show|open|далі|зберегти|створити|запустити|пошук|сортувати)/i.test(lower); +} + +function isNavigationSection(sectionName: string): boolean { + return /(navigation|menu|header|footer|breadcrumb)/i.test(sectionName); +} + +function isContentSection(sectionName: string): boolean { + return /(content|list|grid|cards|results|items|catalog)/i.test(sectionName); +} + +function isPrimaryContentSection(sectionName: string): boolean { + return /(content|list|cards|results|grid|catalog|items)/i.test(sectionName); +} + +function isLikelyGlobalCategory(name: string): boolean { + return CATEGORY_LABELS.has(normalizeCandidateLabel(name)); +} + +function getLinkKind(element: ResearchElement, sectionName: string): InteractionCandidate['kind'] { + if (isAccountLikeLink(element)) { + return 'account'; + } + if (isCategoryLikeLink(element)) { + return 'category'; + } + if (isLikelyDetailLink(element, sectionName)) { + return 'detail'; + } + + return 'category'; +} + +function isLikelyDetailLink(element: ResearchElement, sectionName: string): boolean { + const css = element.css?.toLowerCase() || ''; + const name = element.name.trim(); + + if (!name || isLikelyGlobalCategory(name)) { + return false; + } + if (isUtilityOrCategoryLocator(element.css) || isUtilityOrCategoryLocator(element.xpath)) { + return false; + } + if (/(login|sign in|register|feedback|search|home)/i.test(name)) { + return false; + } + if (/(card|poster|movie|film|title|item)/i.test(css)) { + return true; + } + if (hasDetailPathSignal(element.css) || hasDetailPathSignal(element.xpath)) { + return true; + } + if (isPrimaryContentSection(sectionName) && /^a:has-text\(/i.test(element.css || '') && name.length >= 8) { + return true; + } + if (isPrimaryContentSection(sectionName) && name.length >= 8 && /[:,'"’`-]/.test(name)) { + return true; + } + if (isPrimaryContentSection(sectionName) && name.length >= 14 && /\s+\S+\s+\S+/.test(name)) { + return true; + } + + return false; +} + +function scoreCandidate(candidate: InteractionCandidate): number { + let score = 0; + + if (candidate.kind === 'detail') { + score += 100; + } + if (candidate.kind === 'account') { + score -= 20; + } + if (candidate.kind === 'button') { + score += 60; + } + if (candidate.kind === 'category') { + score += 10; + } + if (isContentSection(candidate.sectionName)) { + score += 40; + } + if (candidate.container && /(content|list|grid|cards|results|items|catalog)/i.test(candidate.container)) { + score += 20; + } + if (candidate.element.css && /(card|poster|movie|film|title|item)/i.test(candidate.element.css)) { + score += 20; + } + if (candidate.element.name.trim().length > 12) { + score += 10; + } + + return score; +} + +function isUtilityOrCategoryLocator(locator: string | null): boolean { + if (!locator) { + return false; + } + + return /\/(series|cartoons|films|fcartoon|collections?|anime|dorama|mylist|mylists|login|register|feedback|abuse|search|tags?|persons?|actors?)(?:\/?$|[?#"'`)]|$)/i.test(locator); +} + +function hasDetailPathSignal(locator: string | null): boolean { + if (!locator) { + return false; + } + + return /\/(film|films|movie|movies|serial|serials)\/.+/i.test(locator); +} + +function isShellLocator(locator: string | null | undefined): boolean { + if (!locator) { + return false; + } + + return /(nav\[role="navigation"\]|header|menu|breadcrumb|footer)/i.test(locator); +} + +function isCategoryLikeLink(element: ResearchElement): boolean { + const label = normalizeCandidateLabel(element.name); + if (CATEGORY_LABELS.has(label)) { + return true; + } + + return isUtilityOrCategoryLocator(element.css) || isUtilityOrCategoryLocator(element.xpath); +} + +function isAccountLikeLink(element: ResearchElement): boolean { + return ACCOUNT_LABELS.has(normalizeCandidateLabel(element.name)); +} + +function collectNavigationLabels(sections: ReturnType): Set { + const labels = new Set(); + + for (const section of sections) { + if (!isNavigationSection(section.name.toLowerCase())) { + continue; + } + + for (const element of section.elements) { + const label = normalizeCandidateLabel(element.name); + if (!label) { + continue; + } + labels.add(label); + } + } + + return labels; +} + +function isShellLikeListSection(section: ReturnType[number], blockedLabels: Set): boolean { + if (!/^list$/i.test(section.name.trim())) { + return false; + } + if (section.elements.length === 0) { + return false; + } + + let shellSignals = 0; + for (const element of section.elements) { + const label = normalizeCandidateLabel(element.name); + if (!label) { + continue; + } + if (blockedLabels.has(label)) { + shellSignals++; + continue; + } + if (CATEGORY_LABELS.has(label) || ACCOUNT_LABELS.has(label) || SEARCH_LABELS.has(label)) { + shellSignals++; + continue; + } + const role = getElementRole(element); + if (role === 'textbox' || role === 'button') { + shellSignals++; + } + } + + return shellSignals >= Math.max(3, Math.ceil(section.elements.length * 0.6)); +} + +function normalizeCandidateLabel(label: string): string { + return label.trim().toLowerCase(); +} + +function getElementRole(element: ResearchElement): string { + return (element.aria?.role || element.type || '').toLowerCase(); +} + +function xpathStringLiteral(value: string): string { + if (!value.includes("'")) { + return `'${value}'`; + } + if (!value.includes('"')) { + return `"${value}"`; + } + + const parts = value.split("'").map((part) => `'${part}'`); + return `concat(${parts.join(`, "'", `)})`; +} + +async function wait(timeout: number): Promise { + await new Promise((resolve) => setTimeout(resolve, timeout)); +} diff --git a/boat/doc-collector/src/cli.ts b/boat/doc-collector/src/cli.ts index 40a84c0..1f9a71b 100644 --- a/boat/doc-collector/src/cli.ts +++ b/boat/doc-collector/src/cli.ts @@ -95,6 +95,7 @@ export function createDocsCommands(name = 'docs'): Command { maxPages: 100, output: 'docs', screenshot: true, + interactive: false, collapseDynamicPages: true, scope: 'site', includePaths: [], diff --git a/boat/doc-collector/src/config.ts b/boat/doc-collector/src/config.ts index 79ed513..42eed72 100644 --- a/boat/doc-collector/src/config.ts +++ b/boat/doc-collector/src/config.ts @@ -114,6 +114,7 @@ class DocbotConfigParser { maxPages: 100, output: 'docs', screenshot: true, + interactive: false, collapseDynamicPages: true, scope: 'site', includePaths: [], @@ -155,6 +156,7 @@ interface DocbotConfig { deniedPathSegments?: string[]; minCanActions?: number; minInteractiveElements?: number; + interactive?: boolean; }; } diff --git a/boat/doc-collector/src/docbot.ts b/boat/doc-collector/src/docbot.ts index 70d03f0..bd3c175 100644 --- a/boat/doc-collector/src/docbot.ts +++ b/boat/doc-collector/src/docbot.ts @@ -41,7 +41,7 @@ class DocBot { config: this.options.docsConfig, path: this.options.path, }); - this.documentarian = new Documentarian(this.explorBot.getProvider(), this.config); + this.documentarian = new Documentarian(this.explorBot.getProvider(), this.config, this.explorBot.getExplorer()); this.ensureDirectory(this.configParser.getOutputDir()); this.ensureDirectory(this.getPagesDir()); } @@ -128,18 +128,22 @@ class DocBot { summary: documentation.summary, canCount: documentation.can.length, mightCount: documentation.might.length, + interactionCount: (documentation.interactions || []).length, canActions: documentation.can.map((item) => item.action), mightActions: documentation.might.map((item) => item.action), + interactionActions: (documentation.interactions || []).map((item) => item.action), + qualityNotes: documentation.qualityNotes || [], filePath, }); documented.add(pageKey); - const nextPaths = this.extractNextPaths(state, baseUrl, research); + const nextPaths = this.extractNextPaths(state, baseUrl, research, documentation); + const interactionPriorityPaths = new Set(this.extractInteractionPaths(baseUrl, documentation)); for (const nextPath of nextPaths) { if (documented.has(this.getPageKey(nextPath))) { continue; } - if (stateManager.hasVisitedState(nextPath)) { + if (!interactionPriorityPaths.has(nextPath) && stateManager.hasVisitedState(nextPath)) { continue; } this.enqueuePath(nextPath, queue, queued); @@ -185,10 +189,18 @@ class DocBot { return true; } - private extractNextPaths(state: WebPageState, baseUrl: string, research: string): string[] { + private extractNextPaths(state: WebPageState, baseUrl: string, research: string, documentation?: PageDocumentation): string[] { const paths: string[] = []; const seen = new Set(); + for (const interactionPath of this.extractInteractionPaths(baseUrl, documentation)) { + if (seen.has(interactionPath)) { + continue; + } + seen.add(interactionPath); + paths.push(interactionPath); + } + for (const link of state.links || []) { const nextPath = this.resolveLink(link, baseUrl); if (!nextPath) { @@ -224,11 +236,58 @@ class DocBot { return paths; } + private extractInteractionPaths(baseUrl: string, documentation?: PageDocumentation): string[] { + const paths: string[] = []; + const seen = new Set(); + const interactions = documentation?.interactions; + + for (const interaction of interactions || []) { + if (interaction.targetUrl) { + const nextPath = this.resolveRawUrl(interaction.targetUrl, baseUrl); + if (nextPath && this.isEligibleNextPath(nextPath) && !seen.has(nextPath)) { + seen.add(nextPath); + paths.push(nextPath); + } + } + + for (const discoveredUrl of interaction.discoveredUrls || []) { + const discoveredPath = this.resolveRawUrl(discoveredUrl, baseUrl); + if (!discoveredPath) { + continue; + } + if (!this.isEligibleNextPath(discoveredPath)) { + continue; + } + if (seen.has(discoveredPath)) { + continue; + } + seen.add(discoveredPath); + paths.push(discoveredPath); + } + } + + return paths; + } + + private isEligibleNextPath(nextPath: string): boolean { + if (!shouldCrawlDocPath(nextPath, this.config)) { + return false; + } + if (!this.isInScope(nextPath)) { + return false; + } + return true; + } + private resolveLink(link: Link, baseUrl: string): string | null { + return this.resolveRawUrl(link.url, baseUrl); + } + + private resolveRawUrl(rawUrl: string, baseUrl: string): string | null { let resolved: URL; try { - resolved = new URL(link.url, baseUrl); + resolved = new URL(rawUrl, baseUrl); } catch { return null; } diff --git a/boat/doc-collector/src/docs-renderer.ts b/boat/doc-collector/src/docs-renderer.ts index 0be4736..8bca039 100644 --- a/boat/doc-collector/src/docs-renderer.ts +++ b/boat/doc-collector/src/docs-renderer.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import type { WebPageState } from '../../../src/state-manager.ts'; -import type { PageDocumentation } from './ai/documentarian.ts'; +import type { PageDocumentation, StateTransition } from './ai/documentarian.ts'; function renderPageDocumentation(state: WebPageState, documentation: PageDocumentation): string { const lines: string[] = []; @@ -16,6 +16,28 @@ function renderPageDocumentation(state: WebPageState, documentation: PageDocumen lines.push(''); lines.push(ensureSentence(documentation.summary)); lines.push(''); + + const interactions = documentation.interactions; + if (interactions && interactions.length > 0) { + lines.push('## State Transitions'); + lines.push(''); + for (const transition of interactions) { + lines.push(`### ${transition.action}`); + lines.push(''); + lines.push(`**Before:** ${transition.before}`); + lines.push(''); + lines.push(`**After:** ${transition.after}`); + lines.push(''); + if (transition.newCapabilities && transition.newCapabilities.length > 0) { + lines.push('**New capabilities discovered:**'); + for (const cap of transition.newCapabilities) { + lines.push(`- ${cap}`); + } + lines.push(''); + } + } + } + lines.push('## User Can'); lines.push(''); @@ -50,6 +72,16 @@ function renderPageDocumentation(state: WebPageState, documentation: PageDocumen lines.push(''); } + const qualityNotes = documentation.qualityNotes; + if (qualityNotes && qualityNotes.length > 0) { + lines.push('## Coverage Notes'); + lines.push(''); + for (const note of qualityNotes) { + lines.push(`- ${ensureSentence(note)}`); + } + lines.push(''); + } + return `${lines.join('\n').trimEnd()}\n`; } @@ -79,6 +111,9 @@ function renderSpecIndex(outputDir: string, startPath: string, pages: Documented lines.push(`Purpose: ${ensureSentence(page.summary)}`); lines.push(`Proven actions: ${page.canCount}`); lines.push(`Possible actions: ${page.mightCount}`); + if (page.interactionCount > 0) { + lines.push(`Interactive transitions: ${page.interactionCount}`); + } if (page.title) { lines.push(`Title: ${normalizeInlineText(page.title)}`); } @@ -99,6 +134,22 @@ function renderSpecIndex(outputDir: string, startPath: string, pages: Documented } lines.push(''); } + + if (page.interactionActions.length > 0) { + lines.push('Interactive Findings:'); + for (const action of page.interactionActions.slice(0, 3)) { + lines.push(`- ${normalizeInlineText(action)}`); + } + lines.push(''); + } + + if (page.qualityNotes.length > 0) { + lines.push('Coverage Notes:'); + for (const note of page.qualityNotes) { + lines.push(`- ${ensureSentence(note)}`); + } + lines.push(''); + } } if (skipped.length > 0) { @@ -173,8 +224,11 @@ interface DocumentedPage { summary: string; canCount: number; mightCount: number; + interactionCount: number; canActions: string[]; mightActions: string[]; + interactionActions: string[]; + qualityNotes: string[]; filePath: string; } @@ -184,4 +238,4 @@ interface SkippedPage { } export { renderPageDocumentation, renderSpecIndex, ensureSentence, normalizeAction }; -export type { DocumentedPage, SkippedPage }; +export type { DocumentedPage, SkippedPage, StateTransition }; diff --git a/docs/doc-collector.md b/docs/doc-collector.md index 91f69f8..331e32d 100644 --- a/docs/doc-collector.md +++ b/docs/doc-collector.md @@ -2,15 +2,54 @@ `doc-collector` crawls pages and generates a lightweight spec: -- `output/docs/spec.md` -- `output/docs/pages/*.md` -- `output/research/*.md` +- `output/docs/spec.md` - Main index +- `output/docs/pages/*.md` - Individual page documentation +- `output/research/*.md` - Research data Each page is summarized as: - `Purpose` -- `User Can` -- `User Might` +- `User Can` (proven capabilities) +- `User Might` (assumed capabilities) +- `State Transitions` (when interactive mode is enabled) + +## Features + +### Static Documentation (Default) + +Analyzes pages without interaction: + +- ✅ Researches page structure via Researcher agent +- ✅ Identifies UI elements and navigation +- ✅ Generates documentation from static analysis +- ✅ Fast and reliable + +### Interactive Documentation (Beta) + +When `interactive: true` in config: + +- ✅ **Explores tabs** - Switches between tabs to document all content +- ✅ **Tests buttons** - Clicks buttons to see what happens +- ✅ **Expands sections** - Opens dropdowns, accordions, collapsible panels +- ✅ **Tracks state changes** - Documents before/after states +- ✅ **Graceful fallback** - Falls back to static mode if interactions fail + +**Example output with interactions:** + +```markdown +## State Transitions + +### Switched to tab: Merged +**Before:** 18 elements (tab:3, link:5, text:7) +**After:** Tab content: 21 elements (tab:3, link:8, text:7) + +### Clicked "Save" button +**Before:** Form with 8 fields +**After:** Success message appeared, form cleared +**New capabilities discovered:** +- User can create new runs +- User can see run ID after creation +``` ## Commands @@ -74,7 +113,7 @@ export default { deniedPathSegments: ['callback', 'callbacks', 'logout', 'signout', 'sign_out', 'destroy', 'delete', 'remove'], minCanActions: 1, minInteractiveElements: 3, - // prompt: 'Add domain-specific guidance here', + interactive: false, // Set to true to enable interactive exploration }, }; ``` @@ -84,6 +123,7 @@ export default { | `maxPages` | `100` | Maximum pages to document | | `output` | `'docs'` | Output folder inside `output/` | | `screenshot` | `true` | Allow screenshot-assisted research | +| `interactive` | `false` | Enable interactive exploration (click tabs, buttons, expand sections) | | `prompt` | unset | Extra instructions for the Documentarian | | `collapseDynamicPages` | `true` | Collapse dynamic URLs like `/users/123` and `/users/456` into one crawl key | | `scope` | `'site'` | Crawl breadth mode | @@ -93,6 +133,31 @@ export default { | `minCanActions` | `1` | Minimum proven actions before a page is considered low-signal | | `minInteractiveElements` | `3` | Minimum interactive elements before a page is considered low-signal | +### Interactive Mode Requirements + +When `interactive: true`, Doc-Collector needs: + +1. **AI model with sufficient output capacity** (e.g., Claude 3.5+, Gemini Pro 1.5) +2. **Explorer instance** (automatically provided when running via CLI) +3. **Compatible model configuration** (see `explorbot.config.js`) + +**Example model configuration:** + +```javascript +// explorbot.config.js +{ + ai: { + agents: { + documentarian: { + model: openrouter('google/gemini-pro-1.5'), // High output capacity + }, + }, + }, +} +``` + +**Note:** If the model doesn't have sufficient output capacity, interactive mode will gracefully fall back to static documentation. + ## Scope Modes ### `site` diff --git a/tests/unit/doc-collector.test.ts b/tests/unit/doc-collector.test.ts index 25f3b37..9ce782e 100644 --- a/tests/unit/doc-collector.test.ts +++ b/tests/unit/doc-collector.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test'; import { DocBot } from '../../boat/doc-collector/src/docbot.ts'; import { Documentarian } from '../../boat/doc-collector/src/ai/documentarian.ts'; +import { pickDocActionCandidates } from '../../boat/doc-collector/src/ai/tools.ts'; import { normalizeAction, renderPageDocumentation, renderSpecIndex } from '../../boat/doc-collector/src/docs-renderer.ts'; import { getDocPageKey, shouldCrawlDocPath } from '../../boat/doc-collector/src/path-filter.ts'; import { extractResearchNavigationTargets } from '../../boat/doc-collector/src/research-navigation.ts'; @@ -126,8 +127,11 @@ describe('doc-collector renderer', () => { summary: 'Sign in page', canCount: 7, mightCount: 1, + interactionCount: 1, canActions: ['user can sign in with email and password'], mightActions: ['user might use social login'], + interactionActions: ['Opened detail page: Login help'], + qualityNotes: ['Coverage is complete for the visible sign-in form.'], filePath: 'D:/project/output/docs/pages/users_sign_in.md', }, ], @@ -143,10 +147,15 @@ describe('doc-collector renderer', () => { expect(markdown).toContain('## Overview'); expect(markdown).toContain('### [/users/sign_in](pages/users_sign_in.md)'); expect(markdown).toContain('Proven actions: 7'); + expect(markdown).toContain('Interactive transitions: 1'); expect(markdown).toContain('User Can:'); expect(markdown).toContain('- user can sign in with email and password'); expect(markdown).toContain('User Might:'); expect(markdown).toContain('- user might use social login'); + expect(markdown).toContain('Interactive Findings:'); + expect(markdown).toContain('- Opened detail page: Login help'); + expect(markdown).toContain('Coverage Notes:'); + expect(markdown).toContain('- Coverage is complete for the visible sign-in form.'); expect(markdown).toContain('## Skipped'); expect(markdown).toContain('/users/auth/google_oauth2. Reason: redirected into external auth flow.'); }); @@ -184,6 +193,49 @@ describe('doc-collector scope and signal', () => { }); }); +describe('doc-collector interactive candidate selection', () => { + it('prioritizes content detail links over global navigation categories', () => { + const research = ` +## Content + +| Element | Type | ARIA | CSS | +|------|------|------|------| +| 'Серіали' | link | { role: 'link', text: 'Серіали' } | 'a.menu-link' | +| 'Граф Дракула: Історія кохання' | link | { role: 'link', text: 'Граф Дракула: Історія кохання' } | 'a.movie-card__title' | +| 'Материнська любов' | link | { role: 'link', text: 'Материнська любов' } | 'a.movie-card__title' | +| 'Моя провина: Лондон' | link | { role: 'link', text: 'Моя провина: Лондон' } | 'a.movie-card__title' | + +## Navigation + +| Element | Type | ARIA | CSS | +|------|------|------|------| +| 'Фільми' | link | { role: 'link', text: 'Фільми' } | 'header a[href="/films/"]' | +| '7' | link | { role: 'link', text: '7' } | '.pagination a.current' | +| '8' | link | { role: 'link', text: '8' } | '.pagination a' | +`; + + expect(pickDocActionCandidates(research)).toEqual([ + { label: 'Граф Дракула: Історія кохання', kind: 'detail', section: 'content' }, + { label: 'Материнська любов', kind: 'detail', section: 'content' }, + { label: 'Моя провина: Лондон', kind: 'detail', section: 'content' }, + ]); + }); + it('ignores modal overlay buttons when selecting action candidates', () => { + const research = ` +## overlay: AskTelegram Modal + +> Container: 'ask_modal_overlay' + +| Element | Type | ARIA | CSS | +|------|------|------|------| +| 'Підписатися' | button | { role: 'button', text: 'Підписатися' } | 'ask_modal_yes' | +| 'Ні, дякую' | button | { role: 'button', text: 'Ні, дякую' } | 'ask_modal_no' | +`; + + expect(pickDocActionCandidates(research)).toEqual([]); + }); +}); + describe('documentarian fallback', () => { it('retries with sanitized research after JSON generation failure', async () => { const calls: string[] = []; @@ -229,4 +281,562 @@ describe('documentarian fallback', () => { expect(calls).toHaveLength(2); expect(calls[1]).toContain(''); }); + + it('retries with sanitized research after schema mismatch response', async () => { + const calls: string[] = []; + const provider = { + async generateObject(messages: Array<{ role: string; content: string }>) { + calls.push(messages[1].content); + if (calls.length === 1) { + throw new Error('No object generated: response did not match schema.'); + } + return { + object: { + summary: 'Catalog page', + can: [ + { + action: 'user can browse items', + scope: 'list of items', + evidence: 'Item links are visible', + }, + ], + might: [], + }, + }; + }, + } as any; + + const documentarian = new Documentarian(provider, {}); + const result = await documentarian.document( + { + url: '/catalog', + title: 'Catalog', + }, + `## Content + +| Element | Type | ARIA | CSS | +|------|------|------|------| +| 'Broken row' | link | - | broken +| 'Item A' | link | { role: 'link', text: 'Item A' } | 'a.item' | +` + ); + + expect(result.summary).toBe('Catalog page'); + expect(result.can).toHaveLength(1); + expect(calls).toHaveLength(2); + expect(calls[1]).toContain(''); + }); +}); + +describe('documentarian output normalization', () => { + it('compacts shell navigation actions and drops weak add-to-list assumptions', async () => { + const provider = { + async generateObject() { + return { + object: { + summary: 'Catalog page', + can: [ + { action: "user can navigate to the 'Serials' section", scope: 'page-level', evidence: 'visible in navigation' }, + { action: "user can navigate to the 'Cartoons' section", scope: 'page-level', evidence: 'visible in navigation' }, + { action: "user can navigate to the 'Films' section", scope: 'page-level', evidence: 'visible in navigation' }, + { action: "user can click the 'My Lists' link to navigate to their personal lists page", scope: 'page-level', evidence: 'visible in navigation' }, + { action: "user can click the 'Login' link to access the login page", scope: 'page-level', evidence: 'visible in navigation' }, + { action: 'user can type a search query in the search textbox and press the search button to perform a search', scope: 'page-level', evidence: 'textbox and button visible' }, + { action: 'user can navigate between pages using the pagination links', scope: 'page-level', evidence: 'pagination visible' }, + { action: 'user can navigate to the external streaming site "Watch online"', scope: 'page-level', evidence: 'external link visible' }, + ], + might: [ + { action: 'user might be able to click on an individual film item to view its detail page', scope: 'one item', evidence: 'Typical catalog pages display film thumbnails that are clickable.' }, + { action: 'user might be able to add a film to a personal list from the list view', scope: 'one item', evidence: "The presence of a 'My Lists' menu suggests functionality to manage lists, though no add-to-list UI is shown." }, + ], + }, + }; + }, + } as any; + + const documentarian = new Documentarian(provider, {}); + const result = await documentarian.document( + { + url: '/films/best/2025/page/7/', + title: 'Films', + }, + `## Menu + +| Element | Type | ARIA | CSS | +|------|------|------|------| +| 'Serials' | link | { role: 'link', text: 'Serials' } | 'a[href="/series/"]' | +| 'Cartoons' | link | { role: 'link', text: 'Cartoons' } | 'a[href="/cartoons/"]' | +| 'Films' | link | { role: 'link', text: 'Films' } | 'a[href="/films/"]' | +| 'My Lists' | link | { role: 'link', text: 'My Lists' } | 'a[href="/mylists/"]' | +| 'Login' | link | { role: 'link', text: 'Login' } | 'a[href="/login/"]' | +` + ); + + expect(result.can.map((item) => item.action)).toEqual([ + 'user can navigate to major site sections using the visible navigation links', + 'user can access account-related pages from the visible header links', + 'user can type a search query in the search textbox and press the search button to perform a search', + 'user can navigate between pages using the pagination links', + 'user can open external links shown on the page', + ]); + expect(result.might.map((item) => item.action)).toEqual(['user might be able to click on an individual film item to view its detail page']); + expect((result as any).qualityNotes).toEqual(['Research did not provide a dedicated content section, so content-specific behavior may be under-documented.']); + }); +}); + +describe('documentarian interactive mode', () => { + it('uses static mode when interactive is disabled', async () => { + const provider = { + async generateObject() { + return { + object: { + summary: 'Static page', + can: [{ action: 'user can view', scope: 'page-level', evidence: 'visible' }], + might: [], + }, + }; + }, + getModelForAgent() { + return 'mock-model'; + }, + } as any; + + const documentarian = new Documentarian(provider, { docs: { interactive: false } }); + const result = await documentarian.document( + { + url: '/test', + title: 'Test', + }, + '## Content\nStatic research' + ); + + expect(result.summary).toBe('Static page'); + expect(result.can).toHaveLength(1); + }); + + it('uses static mode when explorer is not provided', async () => { + const provider = { + async generateObject() { + return { + object: { + summary: 'Static page', + can: [{ action: 'user can view', scope: 'page-level', evidence: 'visible' }], + might: [], + }, + }; + }, + } as any; + + const documentarian = new Documentarian(provider, { docs: { interactive: true } }); + const result = await documentarian.document( + { + url: '/test', + title: 'Test', + }, + '## Content\nStatic research' + ); + + expect(result.summary).toBe('Static page'); + }); + + it('keeps only meaningful interactive transitions', () => { + const documentarian = new Documentarian({} as any, { docs: { interactive: true } }); + const interactions = (documentarian as any).getMeaningfulInteractions([ + { action: 'Opened detail page: Movie A', before: '1', after: '2', targetUrl: '/movies/a' }, + { action: 'Opened pagination page: 8', before: '1', after: '2', targetUrl: '/films/page/8' }, + { action: 'Switched to tab: Merged', before: '1', after: '2' }, + { action: 'Activated button: Save', before: '1', after: '2' }, + { action: 'Opened category page: Cartoons', before: '1', after: '2', targetUrl: '/cartoons/' }, + { action: 'I.click("Cartoons")', before: '1', after: '2', targetUrl: '/cartoons/' }, + ]); + + expect(interactions).toHaveLength(4); + expect(interactions.map((item: any) => item.action)).toEqual(['Opened detail page: Movie A', 'Opened pagination page: 8', 'Switched to tab: Merged', 'Activated button: Save']); + }); + + it('does not render empty new-capabilities block for transitions without discoveries', () => { + const markdown = renderPageDocumentation( + { + url: '/branches', + title: 'Branches', + }, + { + summary: 'Branches page', + can: [], + might: [], + interactions: [ + { + action: 'Switched to tab: Merged', + before: '12 elements (tab:2, link:4, button:2)', + after: 'Tab content: 21 elements (link:8, button:3)', + newCapabilities: [], + }, + ], + } as any + ); + + expect(markdown).toContain('## State Transitions'); + expect(markdown).not.toContain('**New capabilities discovered:**'); + }); + + it('falls back to static mode when interactive mode fails', async () => { + const provider = { + async generateWithTools() { + throw new Error('Tool execution failed: interaction error'); + }, + async generateObject() { + return { + object: { + summary: 'Static fallback', + can: [{ action: 'user can view', scope: 'page-level', evidence: 'fallback' }], + might: [], + }, + }; + }, + getModelForAgent() { + return 'mock-model'; + }, + } as any; + + const mockExplorer = { + getStateManager() { + return { + getCurrentState() { + return { + url: '/test', + title: 'Test', + ariaSnapshot: '[role: button]', + }; + }, + }; + }, + createAction() { + return { + async execute(command: string) { + return true; + }, + }; + }, + } as any; + + const documentarian = new Documentarian(provider, { docs: { interactive: true } }, mockExplorer); + + const result = await documentarian.document( + { + url: '/test', + title: 'Test', + }, + '## Content\nResearch' + ); + + expect(result.summary).toBe('Static fallback'); + expect(result.can).toHaveLength(1); + }); + + it('falls back to static mode when tool failure is capitalized in error text', async () => { + const provider = { + async generateWithTools() { + throw new Error('Tool execution failed'); + }, + async generateObject() { + return { + object: { + summary: 'Static fallback', + can: [{ action: 'user can view', scope: 'page-level', evidence: 'fallback' }], + might: [], + }, + }; + }, + getModelForAgent() { + return 'mock-model'; + }, + } as any; + + const mockExplorer = { + getStateManager() { + return { + getCurrentState() { + return { + url: '/test', + title: 'Test', + ariaSnapshot: '[role: button]', + }; + }, + }; + }, + createAction() { + return { + async execute(command: string) { + return true; + }, + }; + }, + } as any; + + const documentarian = new Documentarian(provider, { docs: { interactive: true } }, mockExplorer); + + const result = await documentarian.document( + { + url: '/test', + title: 'Test', + }, + '## Content\nResearch' + ); + + expect(result.summary).toBe('Static fallback'); + }); + + it('falls back to static mode when interactive documentation fails JSON validation', async () => { + const provider = { + async generateObject(messages: Array<{ role: string; content: string }>) { + const prompt = messages[1].content; + if (prompt.includes('')) { + throw new Error('Failed to validate JSON. Please adjust your prompt. See failed_generation for more details.'); + } + + return { + object: { + summary: 'Static fallback', + can: [{ action: 'user can view content', scope: 'page-level', evidence: 'fallback after invalid interactive JSON' }], + might: [], + }, + }; + }, + getModelForAgent() { + return 'mock-model'; + }, + } as any; + + const states = [ + { + url: '/films', + title: 'Films', + ariaSnapshot: '[role: link]\n[role: heading]', + }, + { + url: '/films/dracula', + title: 'Dracula', + ariaSnapshot: '[role: heading]\n[role: link]\n[role: img]', + }, + ]; + + let stateIndex = 0; + const mockExplorer = { + getStateManager() { + return { + getCurrentState() { + return states[stateIndex]; + }, + }; + }, + createAction() { + return { + async attempt(command: string) { + if (command.startsWith('I.click')) { + stateIndex = 1; + return true; + } + if (command.startsWith('I.amOnPage')) { + stateIndex = 0; + return true; + } + return false; + }, + }; + }, + } as any; + + const documentarian = new Documentarian(provider, { docs: { interactive: true } }, mockExplorer); + const result = await documentarian.document( + { + url: '/films', + title: 'Films', + }, + `## content + +> Container: 'main' + +| Element | Type | ARIA | CSS | +|------|------|------|------| +| 'Граф Дракула: Історія кохання' | link | { role: 'link', text: 'Граф Дракула: Історія кохання' } | 'a.movie-card__title' | +` + ); + + expect(result.summary).toBe('Static fallback'); + expect((result as any).interactions).toBeUndefined(); + }); + + it('does not call tool fallback when deterministic interactions are unavailable', async () => { + const provider = { + async generateWithTools() { + throw new Error('tool fallback should not be used'); + }, + async generateObject(messages: Array<{ role: string; content: string }>) { + const prompt = messages[1].content; + expect(prompt).not.toContain(''); + + return { + object: { + summary: 'Static page', + can: [{ action: 'user can view content', scope: 'page-level', evidence: 'static research only' }], + might: [], + }, + }; + }, + getModelForAgent() { + return 'mock-model'; + }, + } as any; + + const mockExplorer = { + getStateManager() { + return { + getCurrentState() { + return { + url: '/test', + title: 'Test', + ariaSnapshot: '[role: tab]', + }; + }, + }; + }, + createAction() { + return { + async execute(command: string) { + return true; + }, + }; + }, + } as any; + + const documentarian = new Documentarian(provider, { docs: { interactive: true } }, mockExplorer); + + const result = await documentarian.document( + { + url: '/test', + title: 'Test', + }, + '## Content\nLinks only' + ); + + expect(result.summary).toBe('Static page'); + }); +}); + +describe('documentarian interactive defaults', () => { + it('uses static mode by default when interactive is not configured', async () => { + const provider = { + async generateObject() { + return { + object: { + summary: 'Static by default', + can: [{ action: 'user can view', scope: 'page-level', evidence: 'visible' }], + might: [], + }, + }; + }, + async generateWithTools() { + throw new Error('interactive tools should not be called by default'); + }, + } as any; + + const mockExplorer = { + getStateManager() { + return { + getCurrentState() { + return { + url: '/test', + title: 'Test', + ariaSnapshot: '[role: button]', + }; + }, + }; + }, + createAction() { + return { + async execute() { + return true; + }, + }; + }, + } as any; + + const documentarian = new Documentarian(provider, {}, mockExplorer); + const result = await documentarian.document( + { + url: '/test', + title: 'Test', + }, + '## Content\nButtons' + ); + + expect(result.summary).toBe('Static by default'); + }); +}); + +describe('docbot interactive path extraction', () => { + it('adds discovered urls from interactions into next crawl targets', () => { + const bot = new DocBot(); + (bot as any).config = { docs: { scope: 'site' } }; + + const nextPaths = (bot as any).extractNextPaths( + { + url: '/branches', + title: 'Branches', + links: [], + }, + 'https://example.com', + '## Content\nBranches', + { + interactions: [ + { + action: 'Switched to tab: Merged', + before: '12 elements', + after: '21 elements', + discoveredUrls: ['/branches/merged/1', '/branches/merged/2'], + }, + { + action: 'I.click("Save")', + before: '8 elements', + after: '12 elements', + targetUrl: '/runs/123', + discoveredUrls: ['/runs/123/details'], + }, + ], + } + ); + + expect(nextPaths).toEqual(['/branches/merged/1', '/branches/merged/2', '/runs/123', '/runs/123/details']); + }); + + it('prioritizes interaction-discovered paths ahead of generic page links', () => { + const bot = new DocBot(); + (bot as any).config = { docs: { scope: 'site' } }; + + const nextPaths = (bot as any).extractNextPaths( + { + url: '/films/best', + title: 'Films', + links: [ + { title: 'Home', url: '/' }, + { title: 'Series', url: '/series/' }, + ], + }, + 'https://example.com', + '', + { + interactions: [ + { + action: 'Opened detail page: Movie A', + before: '1', + after: '2', + targetUrl: '/movies/a', + discoveredUrls: ['/movies/a/trailer'], + }, + ], + } + ); + + expect(nextPaths).toEqual(['/movies/a', '/movies/a/trailer', '/', '/series/']); + }); }); From a4ffca12771b029fc867d9be5876292f6b467502 Mon Sep 17 00:00:00 2001 From: Denys Kuchma Date: Wed, 27 May 2026 20:49:45 +0300 Subject: [PATCH 2/2] upd doc --- docs/doc-collector.md | 76 ++++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 41 deletions(-) diff --git a/docs/doc-collector.md b/docs/doc-collector.md index 331e32d..bd92ddd 100644 --- a/docs/doc-collector.md +++ b/docs/doc-collector.md @@ -1,4 +1,4 @@ -# Documentation Collection +# Documentation Collection `doc-collector` crawls pages and generates a lightweight spec: @@ -11,7 +11,7 @@ Each page is summarized as: - `Purpose` - `User Can` (proven capabilities) - `User Might` (assumed capabilities) -- `State Transitions` (when interactive mode is enabled) +- `State Transitions` (when interactive mode is enabled and useful) ## Features @@ -19,22 +19,37 @@ Each page is summarized as: Analyzes pages without interaction: -- ✅ Researches page structure via Researcher agent -- ✅ Identifies UI elements and navigation -- ✅ Generates documentation from static analysis -- ✅ Fast and reliable +- вњ… Researches page structure via Researcher agent +- вњ… Identifies UI elements and navigation +- вњ… Generates documentation from static analysis +- вњ… Fast and reliable -### Interactive Documentation (Beta) +### Interactive Documentation When `interactive: true` in config: -- ✅ **Explores tabs** - Switches between tabs to document all content -- ✅ **Tests buttons** - Clicks buttons to see what happens -- ✅ **Expands sections** - Opens dropdowns, accordions, collapsible panels -- ✅ **Tracks state changes** - Documents before/after states -- ✅ **Graceful fallback** - Falls back to static mode if interactions fail +- вњ… Tries selected page interactions before final documentation +- вњ… Can capture state changes after clicking links, buttons, and tab-like controls +- вњ… Can document navigation caused by interaction +- вњ… Can enqueue URLs discovered from successful interactions +- вњ… Falls back to static documentation when interaction results are weak or unreliable -**Example output with interactions:** +This mode is intended for cases where static research alone is not enough, for example: + +- alternate page states such as tabs +- post-click behavior +- item/detail navigation +- documenting what changed after an interaction + +When interaction results are useful, page docs may include: + +- `State Transitions` +- `Before` +- `After` +- `New capabilities discovered` +- `Coverage Notes` + +Example: ```markdown ## State Transitions @@ -113,7 +128,7 @@ export default { deniedPathSegments: ['callback', 'callbacks', 'logout', 'signout', 'sign_out', 'destroy', 'delete', 'remove'], minCanActions: 1, minInteractiveElements: 3, - interactive: false, // Set to true to enable interactive exploration + interactive: false, }, }; ``` @@ -123,7 +138,7 @@ export default { | `maxPages` | `100` | Maximum pages to document | | `output` | `'docs'` | Output folder inside `output/` | | `screenshot` | `true` | Allow screenshot-assisted research | -| `interactive` | `false` | Enable interactive exploration (click tabs, buttons, expand sections) | +| `interactive` | `false` | Enable interaction attempts before final documentation | | `prompt` | unset | Extra instructions for the Documentarian | | `collapseDynamicPages` | `true` | Collapse dynamic URLs like `/users/123` and `/users/456` into one crawl key | | `scope` | `'site'` | Crawl breadth mode | @@ -133,31 +148,6 @@ export default { | `minCanActions` | `1` | Minimum proven actions before a page is considered low-signal | | `minInteractiveElements` | `3` | Minimum interactive elements before a page is considered low-signal | -### Interactive Mode Requirements - -When `interactive: true`, Doc-Collector needs: - -1. **AI model with sufficient output capacity** (e.g., Claude 3.5+, Gemini Pro 1.5) -2. **Explorer instance** (automatically provided when running via CLI) -3. **Compatible model configuration** (see `explorbot.config.js`) - -**Example model configuration:** - -```javascript -// explorbot.config.js -{ - ai: { - agents: { - documentarian: { - model: openrouter('google/gemini-pro-1.5'), // High output capacity - }, - }, - }, -} -``` - -**Note:** If the model doesn't have sufficient output capacity, interactive mode will gracefully fall back to static documentation. - ## Scope Modes ### `site` @@ -195,8 +185,12 @@ Softer boundary than `subtree`: keep the same scope root, its descendants, and c - same-origin only - visited pages are tracked through the state manager - dead loops are stopped -- next targets are discovered from links and research navigation +- next targets are discovered from links, research navigation, and successful interaction results - low-signal pages can be skipped +- interactive mode does not replace static documentation; it augments it +- static mode is unchanged when `interactive` is disabled +- if interaction-driven generation fails, the collector falls back to static documentation +- output quality still depends on research quality ## Related Docs