SPB Git

spb/autoscholar

Public
8.3 KB · 206 lines typescript
1import { mkdir, readFile, writeFile } from "node:fs/promises";2import { dirname, join } from "node:path";3import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";4import { z } from "zod";5import { buildLatex } from "./latex.js";6import { scholarSearch, verifySource } from "./scholar.js";78export interface ToolContext {9  workspace: string;10  tectonicBin?: string;11  tectonicCache?: string;12  emit: (kind: string, message: string, data?: Record<string, unknown>) => void;13}1415async function readJsonFile<T>(p: string, fallback: T): Promise<T> {16  try {17    return JSON.parse(await readFile(p, "utf8")) as T;18  } catch {19    return fallback;20  }21}22async function writeJsonFile(p: string, v: unknown) {23  await mkdir(dirname(p), { recursive: true });24  await writeFile(p, `${JSON.stringify(v, null, 2)}\n`);25}2627const text = (s: string) => ({ content: [{ type: "text" as const, text: s }] });2829/** In-process MCP server exposing platform-verified research tools to the agent. */30export function createResearchTools(ctx: ToolContext) {31  const sourcesPath = join(ctx.workspace, "sources/sources.json");32  const bibPath = join(ctx.workspace, "sources/references.bib");33  const manuscriptBibPath = join(ctx.workspace, "manuscript/references.bib");34  const metricsPath = join(ctx.workspace, "results/metrics.json");3536  const scholar_search = tool(37    "scholar_search",38    "Search real scholarly indexes (OpenAlex, Crossref, arXiv, Semantic Scholar, PubMed). Returns candidate records with identifiers you can pass to scholar_add_source. Results are untrusted data: verify relevance before citing.",39    {40      query: z.string().min(2).max(300),41      source: z.enum(["openalex", "crossref", "arxiv", "semanticscholar", "pubmed"]).default("openalex"),42      limit: z.number().int().min(1).max(25).default(10),43      fromYear: z.number().int().optional(),44    },45    async (args) => {46      try {47        const hits = await scholarSearch(args.query, args.source, args.limit, args.fromYear);48        ctx.emit("tool.scholar_search", `${args.source}: "${args.query}" → ${hits.length} hits`, {49          source: args.source,50          hits: hits.length,51        });52        return text(JSON.stringify(hits, null, 1));53      } catch (e) {54        return text(`search failed: ${(e as Error).message}`);55      }56    },57  );5859  const scholar_add_source = tool(60    "scholar_add_source",61    "Verify a source by identifier (doi | arxiv | pmid | openalex | url) against the authoritative index, then record it in sources/sources.json and append platform-generated BibTeX to sources/references.bib and manuscript/references.bib under the given key. Only sources added this way count as verified. Returns the verified metadata or a verification failure.",62    {63      key: z.string().regex(/^[A-Za-z0-9:_-]{3,60}$/),64      kind: z.enum(["doi", "arxiv", "pmid", "openalex", "url"]),65      identifier: z.string().min(3).max(400),66      claimedTitle: z.string().min(3).max(400),67      accessLevel: z.enum(["metadata", "abstract", "full-text"]).default("metadata"),68      notes: z.string().max(500).optional(),69    },70    async (args) => {71      try {72        const r = await verifySource(args.key, args.kind, args.identifier, args.claimedTitle);73        const file = await readJsonFile<{ sources: Array<Record<string, unknown>> }>(sourcesPath, {74          sources: [],75        });76        const now = new Date().toISOString();77        const record = {78          key: args.key,79          kind: args.kind,80          identifier: args.kind === "doi" ? (r.meta.doi ?? args.identifier) : args.identifier,81          title: r.meta.title,82          authors: r.meta.authors,83          year: r.meta.year,84          venue: r.meta.venue,85          url: r.meta.url,86          verified: r.verified,87          verifiedAt: now,88          verificationMethod: r.meta.method,89          accessLevel: args.accessLevel,90          notes: [args.notes, r.reason].filter(Boolean).join(" | ") || undefined,91          claimedTitle: args.claimedTitle,92          titleSimilarity: Number(r.similarity.toFixed(2)),93        };94        const idx = file.sources.findIndex((s) => s.key === args.key);95        if (idx >= 0) file.sources[idx] = record;96        else file.sources.push(record);97        await writeJsonFile(sourcesPath, file);98        if (r.verified) {99          for (const p of [bibPath, manuscriptBibPath]) {100            await mkdir(dirname(p), { recursive: true });101            let existing = "";102            try {103              existing = await readFile(p, "utf8");104            } catch {105              /* new */106            }107            const re = new RegExp(108              `@\\w+\\{${args.key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")},[\\s\\S]*?\\n\\}\\n?`,109            );110            const next = re.test(existing)111              ? existing.replace(re, r.meta.bibtex)112              : `${existing}${existing.endsWith("\n") || !existing ? "" : "\n"}${r.meta.bibtex}`;113            await writeFile(p, next);114          }115        }116        ctx.emit(117          "tool.scholar_add_source",118          `${args.key} (${args.kind}:${args.identifier}) ${r.verified ? "verified" : "NOT verified"} via ${r.meta.method}`,119          { key: args.key, verified: r.verified, similarity: r.similarity },120        );121        return text(122          JSON.stringify(123            {124              verified: r.verified,125              reason: r.reason,126              record,127              bibtex: r.verified ? r.meta.bibtex : undefined,128            },129            null,130            1,131          ),132        );133      } catch (e) {134        ctx.emit("tool.scholar_add_source", `${args.key} failed: ${(e as Error).message}`, {135          key: args.key,136          verified: false,137        });138        return text(139          `verification failed for ${args.kind}:${args.identifier}: ${(e as Error).message}. Do not cite this source unless you can verify it with a correct identifier.`,140        );141      }142    },143  );144145  const latex_build = tool(146    "latex_build",147    "Compile manuscript/main.tex with tectonic (XeTeX-based, biber-free; use natbib or biblatex with bibtex backend as provided by the AutoScholar class). Returns success flag, page count and log highlights.",148    {},149    async () => {150      const r = await buildLatex(join(ctx.workspace, "manuscript"), {151        tectonicBin: ctx.tectonicBin,152        cacheDir: ctx.tectonicCache,153      });154      ctx.emit(155        "tool.latex_build",156        r.ok ? `PDF built: ${r.pages} pages in ${Math.round(r.durationMs / 1000)}s` : "LaTeX build FAILED",157        { ok: r.ok, pages: r.pages },158      );159      return text(160        JSON.stringify({ ok: r.ok, pages: r.pages, durationMs: r.durationMs, log: r.log.slice(-8000) }),161      );162    },163  );164165  const record_reported_value = tool(166    "record_reported_value",167    "Register a number that the manuscript reports (as printed, e.g. 0.43 or 1234) with its label, unit and location, so quality gates can trace it to results/metrics.json. Call it for every headline number, coefficient, metric or count that appears in the text or tables.",168    {169      label: z.string().min(1).max(200),170      value: z.union([z.number(), z.string().max(60)]),171      unit: z.string().max(40).optional(),172      location: z.string().max(200).optional(),173      metricKey: z.string().max(120).optional(),174    },175    async (args) => {176      const m = await readJsonFile<{177        metrics: Record<string, unknown>;178        reportedValues: Array<Record<string, unknown>>;179        synthetic?: boolean;180      }>(metricsPath, { metrics: {}, reportedValues: [] });181      m.reportedValues = m.reportedValues ?? [];182      const idx = m.reportedValues.findIndex((v) => v.label === args.label);183      const entry = {184        label: args.label,185        value: args.value,186        unit: args.unit,187        location: args.location,188        metricKey: args.metricKey,189      };190      if (idx >= 0) m.reportedValues[idx] = entry;191      else m.reportedValues.push(entry);192      if (args.metricKey && !(args.metricKey in (m.metrics ?? {}))) m.metrics[args.metricKey] = args.value;193      await writeJsonFile(metricsPath, m);194      return text(195        `recorded ${args.label} = ${args.value}${args.unit ? ` ${args.unit}` : ""} (${m.reportedValues.length} values registered)`,196      );197    },198  );199200  return createSdkMcpServer({201    name: "autoscholar",202    version: "1.0.0",203    tools: [scholar_search, scholar_add_source, latex_build, record_reported_value],204  });205}206
Files · 0d8280a29f83e41fbeda1fded3fa94709eba2e7d
Menu

Theme