SPB Git forge
7commits 1branches 0releases
229.0 KBsize
maindefault branch
12 days agolast push
TypeScript 91.8% HTML 3.2% JavaScript 3% SQL 1.4% CSS 0.7%
6.8 KB · 124 lines typescript
Raw Blame History
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