Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 37 additions & 13 deletions boat/doc-collector/src/ai/documentarian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -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,
};
}
Expand Down Expand Up @@ -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<typeof stateTransitionSchema>;
type PageDocumentation = z.infer<typeof pageDocumentationSchema> & {
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<z.infer<typeof pageDocumentationSchema>, 'interactions'> & {
interactions?: StateTransition[];
qualityNotes?: string[];
};
Expand Down
80 changes: 60 additions & 20 deletions boat/doc-collector/src/ai/tools.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<DocStateTransition[]> {
export async function collectDocInteractions(explorer: Explorer, state: WebPageState, research: string, config: DocbotConfig = {}): Promise<DocStateTransition[]> {
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;
}

Expand All @@ -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<DocStateTransition[]> {
async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: ResearchElement[]; container?: string; sectionName: string }, restoreUrl: string, maxInteractions: number): Promise<DocStateTransition[]> {
const transitions: DocStateTransition[] = [];

for (const element of tabGroup.elements) {
if (transitions.length >= maxInteractions) {
break;
}

const transition = await executeInteraction(
explorer,
{
Expand Down Expand Up @@ -241,23 +248,21 @@ function findTabGroup(sections: ReturnType<typeof parseResearchSections>): { ele
return null;
}

function findActionCandidates(sections: ReturnType<typeof parseResearchSections>): InteractionCandidate[] {
function findActionCandidates(sections: ReturnType<typeof parseResearchSections>, config: DocbotConfig): InteractionCandidate[] {
const candidates: InteractionCandidate[] = [];
const seen = new Set<string>();
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;
}
Expand All @@ -272,18 +277,21 @@ function findActionCandidates(sections: ReturnType<typeof parseResearchSections>
}
}

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<string>): InteractionCandidate | null {
function toInteractionCandidate(element: ResearchElement, sectionName: string, container: string | null | undefined, navigationLabels: Set<string>, config: DocbotConfig): InteractionCandidate | null {
const role = getElementRole(element);
if (role !== 'link' && role !== 'button' && role !== 'tab') {
return null;
}
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))) {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<typeof parseResearchSections>): Set<string> {
Expand All @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions boat/doc-collector/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions boat/doc-collector/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -154,6 +157,9 @@ interface DocbotConfig {
includePaths?: string[];
excludePaths?: string[];
deniedPathSegments?: string[];
deniedActionLabels?: string[];
maxPrimaryCandidates?: number;
maxInteractions?: number;
minCanActions?: number;
minInteractiveElements?: number;
interactive?: boolean;
Expand Down
6 changes: 6 additions & 0 deletions docs/doc-collector.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 |

Expand Down
Loading
Loading