Skip to content

Commit 2bb5486

Browse files
ralyodioclaude
andauthored
feat(ai-core): provider abstraction for Session Notes + Clips (#31)
* feat(ai-core): add provider abstraction for Session Notes + Clips New @pairux/ai-core package — the compute-plane contract for PairUX Pro AI Session Notes and AI Clips (PRD "Replay"). - AiProvider interface: summarizeSession() + selectClips(), text in / validated out - Two drivers: anthropic (managed key or BYOK) and ollama (fully local) - Zod schemas (SessionNote, ClipCandidate[]) as the single source of truth - One-shot auto-repair on malformed replies, then StructuredOutputError so the caller can fall back to manual mode (the PRD's clip-selection contract) - Only transcript text ever crosses a provider boundary; no SDK dependency — the desktop host injects a structurally-typed Anthropic client - Managed default model is Haiku 4.5 to keep the Pro unit economics in envelope (review finding F3) Standalone package — does not touch existing apps. Verified in isolation: typecheck, eslint (0 warnings), build, and 37 unit tests all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ai-core): resolve CodeQL ReDoS alerts and Prettier formatting CI "Lint" job failed on `pnpm format:check`; CodeQL flagged two ReDoS-prone regexes. Both are in this package. - parse.ts: replace the ```-fence and first-bracket regexes with linear index scans (indexOf / char loop) — no backtracking on adversarial model output - providers/ollama.ts: strip trailing slashes with a char loop instead of /\/+$/ - run Prettier over the package (printWidth 100, es5 trailing commas) to satisfy format:check No behavior change; 37 tests still pass, typecheck + eslint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f5d67b5 commit 2bb5486

24 files changed

Lines changed: 1102 additions & 19 deletions

‎packages/ai-core/README.md‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# @pairux/ai-core
2+
3+
Provider abstraction for PairUX Pro **AI Session Notes** and **AI Clips**. This is the
4+
compute-plane contract the desktop host calls into: transcript text in, validated
5+
structured output out.
6+
7+
## What it does
8+
9+
- **One interface** — `AiProvider` with `summarizeSession()` and `selectClips()`.
10+
- **Two drivers** — `anthropic` (managed key **or** BYOK) and `ollama` (fully local).
11+
- **Validated output** — every reply is parsed against Zod schemas (`SessionNote`,
12+
`ClipCandidate[]`). Malformed output triggers exactly one auto-repair retry, then
13+
throws `StructuredOutputError` so the caller can fall back to manual mode — the
14+
contract the PRD specifies for clip selection.
15+
- **Text-only boundary** — a provider only ever receives `TranscriptInput` (text +
16+
metadata). Audio and video never cross this package, by construction.
17+
18+
## Usage
19+
20+
```ts
21+
import Anthropic from '@anthropic-ai/sdk';
22+
import { createProvider } from '@pairux/ai-core';
23+
24+
// Managed key or BYOK — the host injects the real SDK client.
25+
const provider = createProvider({ kind: 'anthropic', client: new Anthropic() });
26+
27+
const note = await provider.summarizeSession(transcript, { template: 'pair-programming' });
28+
const clips = await provider.selectClips(transcript, { platform: 'shorts', maxClips: 5 });
29+
```
30+
31+
Fully-local mode (nothing leaves the device):
32+
33+
```ts
34+
const provider = createProvider({ kind: 'ollama', model: 'llama3.1' });
35+
```
36+
37+
## Notes
38+
39+
- The managed default model is **Haiku 4.5** — see `config.ts`. This keeps the
40+
summarize + clip-select pair (two transcript-text-only calls per session) inside
41+
the Pro unit-economics envelope; BYOK callers can override for higher quality.
42+
- `ai-core` deliberately does **not** depend on `@anthropic-ai/sdk`. The host passes a
43+
client that structurally matches `AnthropicClientLike`, keeping this package light
44+
and unit-testable offline.
45+
46+
## Scope
47+
48+
This is the foundation slice for the "Replay" PRD. Recording (P0-1), local Whisper
49+
transcription (P0-2), and the ffmpeg clip renderer (P0-4) live in the desktop app and
50+
depend on this contract; they are not part of this package.

‎packages/ai-core/package.json‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "@pairux/ai-core",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"main": "./dist/index.js",
7+
"types": "./dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"types": "./dist/index.d.ts",
11+
"import": "./dist/index.js"
12+
}
13+
},
14+
"scripts": {
15+
"build": "tsc",
16+
"dev": "tsc --watch",
17+
"typecheck": "tsc --noEmit",
18+
"lint": "eslint src/",
19+
"lint:fix": "eslint src/ --fix",
20+
"test": "vitest run",
21+
"test:watch": "vitest",
22+
"test:coverage": "vitest run --coverage",
23+
"clean": "rm -rf dist"
24+
},
25+
"dependencies": {
26+
"zod": "^3.24.0"
27+
},
28+
"devDependencies": {
29+
"typescript": "^5.7.0",
30+
"vitest": "^3.2.0"
31+
}
32+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { z } from 'zod';
3+
import { completeStructured, type CompleteFn } from './complete.js';
4+
import { StructuredOutputError } from './errors.js';
5+
6+
const schema = z.object({ ok: z.boolean() });
7+
8+
describe('completeStructured', () => {
9+
it('returns parsed output when the first reply is valid', async () => {
10+
const complete: CompleteFn = vi.fn(() => Promise.resolve('{"ok": true}'));
11+
await expect(completeStructured(complete, { system: 's', user: 'u' }, schema)).resolves.toEqual(
12+
{ ok: true }
13+
);
14+
expect(complete).toHaveBeenCalledTimes(1);
15+
});
16+
17+
it('retries exactly once and succeeds on the repair', async () => {
18+
const complete = vi
19+
.fn<CompleteFn>()
20+
.mockResolvedValueOnce('sorry, here: not json')
21+
.mockResolvedValueOnce('{"ok": false}');
22+
const result = await completeStructured(complete, { system: 's', user: 'u' }, schema);
23+
expect(result).toEqual({ ok: false });
24+
expect(complete).toHaveBeenCalledTimes(2);
25+
});
26+
27+
it('asks for JSON-only on the repair attempt', async () => {
28+
const complete = vi
29+
.fn<CompleteFn>()
30+
.mockResolvedValueOnce('nope')
31+
.mockResolvedValueOnce('{"ok": true}');
32+
await completeStructured(complete, { system: 's', user: 'u' }, schema);
33+
const secondCall = complete.mock.calls[1]?.[0];
34+
expect(secondCall?.user).toContain('ONLY the JSON');
35+
expect(secondCall?.system).toBe('s');
36+
});
37+
38+
it('rethrows StructuredOutputError after a second failure (caller falls back to manual mode)', async () => {
39+
const complete: CompleteFn = vi.fn(() => Promise.resolve('still not json'));
40+
await expect(
41+
completeStructured(complete, { system: 's', user: 'u' }, schema)
42+
).rejects.toBeInstanceOf(StructuredOutputError);
43+
expect(complete).toHaveBeenCalledTimes(2);
44+
});
45+
});

‎packages/ai-core/src/complete.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { ZodType } from 'zod';
2+
import { StructuredOutputError } from './errors.js';
3+
import { parseStructured } from './parse.js';
4+
5+
export interface Prompt {
6+
system: string;
7+
user: string;
8+
}
9+
10+
/** A minimal chat completion: text in, text out. Every provider reduces to this. */
11+
export type CompleteFn = (prompt: Prompt) => Promise<string>;
12+
13+
/**
14+
* Run a prompt and validate the reply against `schema`.
15+
*
16+
* Implements the PRD's contract: malformed output triggers exactly one
17+
* auto-repair retry (re-asking for JSON only); a second failure rethrows the
18+
* {@link StructuredOutputError} so the caller can fall back to manual mode.
19+
*/
20+
export async function completeStructured<T>(
21+
complete: CompleteFn,
22+
prompt: Prompt,
23+
schema: ZodType<T>
24+
): Promise<T> {
25+
const first = await complete(prompt);
26+
try {
27+
return parseStructured(first, schema);
28+
} catch (error) {
29+
if (!(error instanceof StructuredOutputError)) {
30+
throw error;
31+
}
32+
const repaired = await complete({
33+
system: prompt.system,
34+
user: `${prompt.user}\n\nYour previous reply could not be parsed. Reply again with ONLY the JSON value — no prose, no explanation, no code fences.`,
35+
});
36+
return parseStructured(repaired, schema);
37+
}
38+
}

‎packages/ai-core/src/config.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Provider defaults.
3+
*
4+
* The managed-key default is deliberately Haiku 4.5. Summarize + clip-select is
5+
* ~2 transcript-text-only calls per session; at Haiku pricing that keeps the
6+
* managed path inside the Pro unit-economics envelope (review finding F3 — the
7+
* "<$0.40/user/mo" guardrail is only reachable at Haiku tier). BYOK callers can
8+
* override `model` with any Anthropic model for higher-quality summaries.
9+
*/
10+
export const DEFAULT_ANTHROPIC_MODEL = 'claude-haiku-4-5';
11+
export const DEFAULT_OLLAMA_MODEL = 'llama3.1';
12+
export const DEFAULT_OLLAMA_BASE_URL = 'http://127.0.0.1:11434';
13+
export const DEFAULT_MAX_TOKENS = 2048;
14+
export const DEFAULT_MAX_CLIPS = 6;

‎packages/ai-core/src/errors.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Thrown when a provider's response cannot be parsed into the required structured
3+
* shape. Callers catch this to fall back to manual mode (e.g. the clip-review UI
4+
* lets the host cut clips by hand when selection fails).
5+
*/
6+
export class StructuredOutputError extends Error {
7+
/** The raw model text that failed to parse, preserved for logging and retry. */
8+
readonly raw: string;
9+
10+
constructor(message: string, raw: string, options?: ErrorOptions) {
11+
super(message, options);
12+
this.name = 'StructuredOutputError';
13+
this.raw = raw;
14+
}
15+
}

‎packages/ai-core/src/index.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* @pairux/ai-core — provider abstraction for PairUX Pro AI Session Notes + Clips.
3+
*
4+
* One interface ({@link AiProvider}), two drivers (Anthropic for managed/BYOK,
5+
* Ollama for fully-local), Zod-validated structured output, and a one-shot
6+
* auto-repair on malformed replies. Only transcript text ever crosses a provider
7+
* boundary.
8+
*/
9+
export * from './types.js';
10+
export * from './schemas.js';
11+
export * from './templates.js';
12+
export * from './config.js';
13+
export * from './errors.js';
14+
export * from './parse.js';
15+
export * from './transcript.js';
16+
export * from './complete.js';
17+
export * from './prompts.js';
18+
export * from './providers/anthropic.js';
19+
export * from './providers/ollama.js';
20+
export * from './providers/factory.js';

‎packages/ai-core/src/parse.test.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { z } from 'zod';
3+
import { StructuredOutputError } from './errors.js';
4+
import { extractJson, parseStructured } from './parse.js';
5+
6+
const schema = z.object({ ok: z.boolean() });
7+
8+
describe('extractJson', () => {
9+
it('unwraps a ```json fence', () => {
10+
expect(extractJson('```json\n{"ok": true}\n```')).toBe('{"ok": true}');
11+
});
12+
13+
it('unwraps a bare ``` fence', () => {
14+
expect(extractJson('```\n[1, 2]\n```')).toBe('[1, 2]');
15+
});
16+
17+
it('slices JSON out of surrounding prose', () => {
18+
expect(extractJson('Sure! {"ok": true} hope that helps')).toBe('{"ok": true}');
19+
});
20+
21+
it('slices an array out of prose', () => {
22+
expect(extractJson('here you go: [{"a":1}] done')).toBe('[{"a":1}]');
23+
});
24+
25+
it('returns trimmed input when no JSON is present', () => {
26+
expect(extractJson(' no json here ')).toBe('no json here');
27+
});
28+
});
29+
30+
describe('parseStructured', () => {
31+
it('parses and validates', () => {
32+
expect(parseStructured('{"ok": true}', schema)).toEqual({ ok: true });
33+
});
34+
35+
it('throws StructuredOutputError on invalid JSON', () => {
36+
expect(() => parseStructured('{not json', schema)).toThrow(StructuredOutputError);
37+
});
38+
39+
it('throws StructuredOutputError on schema mismatch', () => {
40+
expect(() => parseStructured('{"ok": "yes"}', schema)).toThrow(StructuredOutputError);
41+
});
42+
43+
it('preserves the raw output on the error', () => {
44+
try {
45+
parseStructured('garbage', schema);
46+
expect.unreachable('should have thrown');
47+
} catch (error) {
48+
expect(error).toBeInstanceOf(StructuredOutputError);
49+
expect((error as StructuredOutputError).raw).toBe('garbage');
50+
}
51+
});
52+
});

‎packages/ai-core/src/parse.ts‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { ZodType } from 'zod';
2+
import { StructuredOutputError } from './errors.js';
3+
4+
const FENCE = '```';
5+
6+
/** True if `text` is a short run of ASCII letters (a code-fence language tag like "json"). */
7+
function isLanguageTag(text: string): boolean {
8+
if (text.length === 0 || text.length > 16) {
9+
return false;
10+
}
11+
for (const char of text) {
12+
const isLetter = (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z');
13+
if (!isLetter) {
14+
return false;
15+
}
16+
}
17+
return true;
18+
}
19+
20+
/** Index of the first `{` or `[` in `raw`, or -1. Linear scan (no regex, no backtracking). */
21+
function firstBracket(raw: string): number {
22+
for (let i = 0; i < raw.length; i += 1) {
23+
const char = raw[i];
24+
if (char === '{' || char === '[') {
25+
return i;
26+
}
27+
}
28+
return -1;
29+
}
30+
31+
/**
32+
* Pull the first JSON value out of a model reply that may wrap it in prose or a
33+
* ```json fence. Uses index scans only (no regex) so it cannot backtrack on
34+
* adversarial input. Best-effort: returns the most plausible JSON substring,
35+
* which `parseStructured` then validates.
36+
*/
37+
export function extractJson(raw: string): string {
38+
const fenceStart = raw.indexOf(FENCE);
39+
if (fenceStart !== -1) {
40+
const afterOpen = fenceStart + FENCE.length;
41+
const fenceEnd = raw.indexOf(FENCE, afterOpen);
42+
if (fenceEnd !== -1) {
43+
let body = raw.slice(afterOpen, fenceEnd);
44+
const newline = body.indexOf('\n');
45+
if (newline !== -1 && isLanguageTag(body.slice(0, newline).trim())) {
46+
body = body.slice(newline + 1);
47+
}
48+
return body.trim();
49+
}
50+
}
51+
52+
const start = firstBracket(raw);
53+
if (start === -1) {
54+
return raw.trim();
55+
}
56+
57+
const opener = raw[start];
58+
const closer = opener === '[' ? ']' : '}';
59+
const end = raw.lastIndexOf(closer);
60+
if (end > start) {
61+
return raw.slice(start, end + 1).trim();
62+
}
63+
return raw.slice(start).trim();
64+
}
65+
66+
/** Extract + JSON.parse + schema-validate. Throws {@link StructuredOutputError} on any failure. */
67+
export function parseStructured<T>(raw: string, schema: ZodType<T>): T {
68+
const json = extractJson(raw);
69+
70+
let parsed: unknown;
71+
try {
72+
parsed = JSON.parse(json) as unknown;
73+
} catch (cause) {
74+
throw new StructuredOutputError('model output was not valid JSON', raw, { cause });
75+
}
76+
77+
const result = schema.safeParse(parsed);
78+
if (!result.success) {
79+
throw new StructuredOutputError(
80+
`model output failed schema validation: ${result.error.message}`,
81+
raw,
82+
{
83+
cause: result.error,
84+
}
85+
);
86+
}
87+
return result.data;
88+
}

0 commit comments

Comments
 (0)