diff --git a/README.md b/README.md index a6e4dd5..02af7f9 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,10 @@ The reference implementation accepts any `ModelAdapter`. Two adapters ship: - `src/adapters/mock-adapter.ts` -- a deterministic adapter for development and testing. No API key required. - `src/adapters/anthropic-adapter.ts` -- the Anthropic API adapter. Activated when `ANTHROPIC_API_KEY` is set. To use it: `export ANTHROPIC_API_KEY=sk-...` and replace the mock adapter with the Anthropic adapter in your code. +### Dogfooding (real LLM call) + +`npm run example:legal-summarization-anthropic` runs the legal-summarization decision-type against a **real** Anthropic call and writes both the COTS (`examples/output/legal-summarization-anthropic.cots.json`) and a run record (`...run-record.json`: adapter, model, timestamps, SHA-256 of the COTS and decision text) so the run is recorded and reproducible. Requires `ANTHROPIC_API_KEY`; without it the script exits with a clear message and the mock example remains the offline path. Validate the resulting COTS with the conformance suite and render it with `se-trace explore`. + ## Decision-types Two decision-types are implemented: diff --git a/package.json b/package.json index 31b7b7b..2e051f2 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "tsc", "test": "vitest run", "example:legal-summarization": "node dist/examples/legal-summarization.js", + "example:legal-summarization-anthropic": "node dist/examples/legal-summarization-anthropic.js", "example:transaction-routing": "node dist/examples/transaction-routing.js" }, "keywords": [ diff --git a/src/examples/legal-summarization-anthropic.ts b/src/examples/legal-summarization-anthropic.ts new file mode 100644 index 0000000..df72e40 --- /dev/null +++ b/src/examples/legal-summarization-anthropic.ts @@ -0,0 +1,97 @@ +/** + * Dogfooding example: produce a COTS from a REAL LLM call via the Anthropic + * adapter (Advancement Plan Phase 5, publisher-side dogfooding). + * + * Run: + * export ANTHROPIC_API_KEY=sk-... + * npm run build + * npm run example:legal-summarization-anthropic + * + * The COTS is written to examples/output/legal-summarization-anthropic.cots.json + * together with a run-record JSON (prompt, model, parameters, timestamps, + * content hashes) so the example is recorded and reproducible. Validate the + * COTS with the conformance suite: + * + * (in source-engineering-conformance) node dist/cli.js validate + * (in source-engineering-tools) node dist/cli/se-trace.js explore -o ./out + * + * Without ANTHROPIC_API_KEY the script exits with a clear message; the mock + * example (example:legal-summarization) remains the offline path. + */ + +import { createHash } from "node:crypto"; +import { writeFileSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Layer1 } from "../layer1/layer1.js"; +import { AnthropicModelAdapter } from "../adapters/anthropic-adapter.js"; +import { LegalSummarizationDecisionType } from "../decision-types/legal-summarization.js"; +import type { SourceArtifact } from "../cots.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const sha256 = (s: string): string => createHash("sha256").update(s).digest("hex"); + +const main = async () => { + if (!process.env.ANTHROPIC_API_KEY) { + console.error( + "ANTHROPIC_API_KEY is not set. This example performs a real LLM call and needs a key.\n" + + "For the offline path, use: npm run example:legal-summarization" + ); + process.exit(1); + } + + const sourceArtifact: SourceArtifact = { + id: "urn:source-engineering:source:example-contract-2026-001", + origin: { + system_name: "Acme Contracts", + system_version: "1.0.0", + configuration_hash: "0000000000000000000000000000000000000000000000000000000000000001", + }, + version: { + content_hash: "1111111111111111111111111111111111111111111111111111111111111111", + issued_at: "2026-10-10T08:00:00Z", + }, + authority: { asserted_by: "acme-contracts", trust_score: 0.95 }, + temporal_validity: { valid_from: "2026-10-10T08:00:00Z", valid_until: null }, + content_reference: { uri: "https://example.com/contracts/2026-001.pdf", media_type: "application/pdf" }, + }; + + const question = { + document: { uri: "https://example.com/contracts/2026-001.pdf", media_type: "application/pdf" }, + }; + + const adapter = new AnthropicModelAdapter(); + const startedAt = new Date().toISOString(); + const layer1 = new Layer1(LegalSummarizationDecisionType, adapter); + const cots = await layer1.invoke(question, [sourceArtifact]); + const finishedAt = new Date().toISOString(); + + const outDir = resolve(__dirname, "..", "..", "examples", "output"); + mkdirSync(outDir, { recursive: true }); + const cotsPath = resolve(outDir, "legal-summarization-anthropic.cots.json"); + writeFileSync(cotsPath, JSON.stringify(cots, null, 2)); + + const runRecord = { + kind: "se-dogfood-run-record", + example: "legal-summarization-anthropic", + adapter: { name: adapter.name, version: adapter.version }, + started_at: startedAt, + finished_at: finishedAt, + cots_id: cots.cots_id, + cots_sha256: sha256(JSON.stringify(cots)), + decision_text_sha256: sha256(cots.decisions[0].decision_text), + note: "Recorded, reproducible run of the Anthropic adapter. Re-running with the same prompt and model version should produce the same COTS shape; the decision text may vary with the model.", + }; + const recordPath = resolve(outDir, "legal-summarization-anthropic.run-record.json"); + writeFileSync(recordPath, JSON.stringify(runRecord, null, 2)); + + console.log(`COTS written to ${cotsPath}`); + console.log(`Run record written to ${recordPath}`); + console.log(`Decision text (first 200 chars): ${cots.decisions[0].decision_text.slice(0, 200)}`); +}; + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/anthropic-adapter.test.ts b/tests/anthropic-adapter.test.ts new file mode 100644 index 0000000..4753023 --- /dev/null +++ b/tests/anthropic-adapter.test.ts @@ -0,0 +1,66 @@ +/** + * Tests for the Anthropic adapter (dogfooding path). The real API is never + * called in tests: fetch is stubbed. Verifies request shape, response + * extraction, error handling, and the missing-key guard. + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { AnthropicModelAdapter } from "../src/adapters/anthropic-adapter.js"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("AnthropicModelAdapter", () => { + it("throws a clear error when no API key is available", () => { + const saved = process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + try { + expect(() => new AnthropicModelAdapter()).toThrow(/API key not found/); + } finally { + if (saved !== undefined) process.env.ANTHROPIC_API_KEY = saved; + } + }); + + it("sends the documented request shape and extracts text blocks", async () => { + const calls: Array<{ url: string; init: RequestInit }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init: RequestInit) => { + calls.push({ url, init }); + return new Response( + JSON.stringify({ + content: [ + { type: "text", text: "Summary: " }, + { type: "tool_use", name: "x" }, + { type: "text", text: "the contract." }, + ], + }), + { status: 200 } + ); + }) + ); + const adapter = new AnthropicModelAdapter({ apiKey: "test-key", model: "claude-sonnet-4-5" }); + const out = await adapter.complete("Summarise this contract."); + expect(out).toBe("Summary: the contract."); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe("https://api.anthropic.com/v1/messages"); + const headers = calls[0].init.headers as Record; + expect(headers["x-api-key"]).toBe("test-key"); + expect(headers["anthropic-version"]).toBe("2023-06-01"); + const body = JSON.parse(calls[0].init.body as string); + expect(body.model).toBe("claude-sonnet-4-5"); + expect(body.messages[0].role).toBe("user"); + expect(body.messages[0].content).toContain("Summarise this contract."); + }); + + it("surfaces API errors with status and body", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("rate limited", { status: 429 })) + ); + const adapter = new AnthropicModelAdapter({ apiKey: "test-key" }); + await expect(adapter.complete("hi")).rejects.toThrow(/Anthropic API error 429/); + }); +});