spb/social-runtime-crawler
Public
TypeScript 91.8%
HTML 3.2%
JavaScript 3%
SQL 1.4%
CSS 0.7%
1import { clamp01, createLogger, truncate, type ActionScore, type AgentDecision, type PageState, type SemanticAction } from "@src/shared";2import { scoreActions, type GainContext } from "./InformationGain.ts";3import { extractJson, type LlmClient } from "./llm.ts";45const log = createLogger("planner");67/**8 * Navigation Agent (§24). Two planners share the same contract:9 * - HeuristicPlanner (Tier 1): argmax information gain, deterministic.10 * - LlmPlanner (Tier 4): sees the compact page state + top-scored actions and picks one; the answer is11 * validated against the action list and falls back to the heuristic on any invalid output.12 * The LLM never sees selectors or raw DOM and cannot invent actions.13 */14export interface Planner {15 readonly name: string;16 plan(ctx: GainContext, step: number): Promise<AgentDecision>;17 readonly tokensUsed: number;18}1920export class HeuristicPlanner implements Planner {21 readonly name = "heuristic";22 readonly tokensUsed = 0;23 async plan(ctx: GainContext, step: number): Promise<AgentDecision> {24 const scores = scoreActions(ctx, ctx.state.actions);25 return decisionFromScores(ctx, step, scores, "heuristic", undefined);26 }27}2829export function decisionFromScores(ctx: GainContext, step: number, scores: ActionScore[], planner: AgentDecision["planner"], reason?: string, chosenId?: string): AgentDecision {30 const best = chosenId ? scores.find((s) => s.action_id === chosenId) ?? scores[0]! : scores[0]!;31 const action = ctx.state.actions.find((a) => a.id === best.action_id)!;32 const why = reason ?? explain(ctx, action, best);33 return {34 step,35 goal: ctx.goal,36 chosen_action: action,37 expected_information_gain: best.information_gain,38 novelty: best.novelty,39 relevance: best.relevance,40 reason: why,41 planner,42 scores: scores.slice(0, 12),43 };44}4546function explain(ctx: GainContext, a: SemanticAction, s: ActionScore): string {47 const e = a.target_ref ? ctx.state.entities.find((x) => x.ref === a.target_ref) : undefined;48 const parts: string[] = [];49 if (e) parts.push(`${e.type} not yet visited`);50 if (s.novelty > 0.7) parts.push("novel content");51 if (s.relevance > 0.7) parts.push("high relevance to goal");52 if (s.source_quality > 0.9) parts.push("confirmed by network + DOM");53 if (a.type === "SCROLL_DOWN") parts.push("reveal more feed items");54 if (a.type === "SEARCH") parts.push("native search seeds the topic");55 if (a.type === "END_SESSION") parts.push("no productive action left");56 if (s.penalties.length) parts.push(`penalties: ${s.penalties.join(",")}`);57 return parts.join("; ") || "best information gain";58}5960const SYSTEM_PROMPT = `You are the navigation planner of a research crawler that observes public social-media content through a normal authenticated browser session (read-only: never like, follow, comment, message or subscribe).61You receive a compact description of the current page, the entities visible on it, and a numbered list of allowed semantic actions with heuristic information-gain scores.62Choose exactly one action id that best advances the goal while avoiding loops and already-visited entities.63Reply with a single JSON object: {"action": "<id>", "expected_information_gain": <0..1>, "reason": "<one concise sentence>"}. No other text.`;6465export class LlmPlanner implements Planner {66 readonly name: string;67 tokensUsed = 0;68 private failures = 0;69 constructor(private readonly llm: LlmClient, private readonly fallback: Planner = new HeuristicPlanner(), private readonly maxTokensBudget = 200_000) {70 this.name = `llm(${llm.name})`;71 }7273 async plan(ctx: GainContext, step: number): Promise<AgentDecision> {74 const scores = scoreActions(ctx, ctx.state.actions);75 if (this.tokensUsed > this.maxTokensBudget || this.failures >= 3) return decisionFromScores(ctx, step, scores, "fallback", "llm budget exhausted or repeated failures");76 // Escalate only when the heuristic is unsure (§50): close top scores, or unknown page.77 const top = scores[0]!;78 const second = scores[1];79 const unsure = !second || top.information_gain - second.information_gain < top.information_gain * 0.25 || ctx.state.classification.confidence < 0.6;80 if (!unsure) return decisionFromScores(ctx, step, scores, "heuristic");8182 try {83 const user = renderPrompt(ctx, scores);84 const res = await this.llm.complete({ system: SYSTEM_PROMPT, user, maxTokens: 300 });85 this.tokensUsed += res.input_tokens + res.output_tokens;86 const json = extractJson(res.text);87 const id = typeof json?.action === "string" ? (json.action as string) : undefined;88 if (!id || !ctx.state.actions.some((a) => a.id === id)) {89 this.failures++;90 log.warn("llm returned invalid action, using heuristic", { reply: truncate(res.text, 120) });91 return decisionFromScores(ctx, step, scores, "fallback", "invalid llm output");92 }93 this.failures = 0;94 const d = decisionFromScores(ctx, step, scores, "llm", typeof json?.reason === "string" ? truncate(json.reason as string, 240) : undefined, id);95 if (typeof json?.expected_information_gain === "number") d.expected_information_gain = clamp01(json.expected_information_gain as number);96 return d;97 } catch (err) {98 this.failures++;99 log.warn("llm planner error, using heuristic", { err: (err as Error).message });100 return decisionFromScores(ctx, step, scores, "fallback", "llm error");101 }102 }103}104105export function renderPrompt(ctx: GainContext, scores: ActionScore[]): string {106 const st: PageState = ctx.state;107 const lines: string[] = [];108 lines.push(`GOAL: ${ctx.goal}`, `MODE: ${ctx.mode}`, `PLATFORM: ${st.platform}`, `PAGE: ${st.classification.page_type} (${st.classification.confidence.toFixed(2)}) ${truncate(st.title, 80)}`, `URL: ${st.url}`, "");109 lines.push("VISIBLE ENTITIES");110 for (const e of st.entities.slice(0, 30)) {111 const flags = [ctx.world.isVisited(e) ? "visited" : "", e.media?.has_video ? "video" : "", e.metrics?.views ? `${e.metrics.views} views` : "", e.metrics?.score ? `score ${e.metrics.score}` : ""].filter(Boolean).join(", ");112 lines.push(`[${e.ref}] ${e.type}: ${truncate(e.name ?? e.text ?? e.platform_id, 90)}${e.author ? ` — by ${truncate(e.author, 40)}` : ""}${flags ? ` (${flags})` : ""}`);113 }114 if (st.entities.length > 30) lines.push(`… ${st.entities.length - 30} more`);115 lines.push("", "ACTIONS (id · label · heuristic gain)");116 const byId = new Map(scores.map((s) => [s.action_id, s]));117 for (const a of st.actions) {118 const s = byId.get(a.id);119 lines.push(`[${a.id}] ${a.label} · gain=${(s?.information_gain ?? 0).toFixed(3)}${s?.penalties.length ? ` · ${s.penalties.join(",")}` : ""}`);120 }121 lines.push("", `RECENT ACTIONS: ${ctx.recentActionTypes.slice(-6).join(" → ") || "none"}`, `STEPS WITHOUT NEW ENTITIES: ${ctx.stepsWithoutNewEntities}`);122 return lines.join("\n");123}124