Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/api/src/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ export type AiFeature =
| "bias_lint"
| "peer_synthesize"
| "framework_extract"
| "meeting_summary";
| "meeting_summary"
| "skip_level_talking_points";

export type ChatMessage = { role: "system" | "user" | "assistant"; content: string };

Expand Down
25 changes: 25 additions & 0 deletions apps/api/src/aiEstimate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { estimateCostUsd, estimateTokensFromText, maxOutputTokensForFeature } from "../src/aiEstimate.js";

describe("ai estimate helpers", () => {
it("estimates tokens from text length", () => {
assert.equal(estimateTokensFromText("abcd"), 1);
assert.ok(estimateTokensFromText("x".repeat(40)) >= 10);
});

it("returns null cost for cursor/ollama", () => {
assert.equal(estimateCostUsd({ provider: "cursor", inputTokens: 1000, outputTokens: 1000 }), null);
assert.equal(estimateCostUsd({ provider: "ollama", inputTokens: 1000, outputTokens: 1000 }), null);
});

it("estimates anthropic cost", () => {
const cost = estimateCostUsd({ provider: "anthropic", inputTokens: 1_000_000, outputTokens: 0 });
assert.equal(cost, 3);
});

it("caps output tokens by feature", () => {
assert.equal(maxOutputTokensForFeature("framework_extract"), 4096);
assert.equal(maxOutputTokensForFeature("evidence_digest"), 1600);
});
});
74 changes: 74 additions & 0 deletions apps/api/src/aiEstimate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { AiFeature, ChatMessage } from "./ai.js";
import { getAiConfig } from "./ai.js";

/** Rough tokenizer: ~4 chars per token for Latin prose. */
export function estimateTokensFromText(text: string): number {
const t = text.trim();
if (!t) return 0;
return Math.max(1, Math.ceil(t.length / 4));
}

export function estimateTokensFromMessages(messages: ChatMessage[]): number {
return messages.reduce((sum, m) => sum + estimateTokensFromText(m.content) + 4, 0);
}

/** Static USD rates per 1M tokens (approx public list prices; Cursor/Ollama → null). */
const RATES: Record<string, { input: number; output: number } | null> = {
anthropic: { input: 3, output: 15 },
openai: { input: 0.15, output: 0.6 },
cursor: null,
ollama: null,
};

export function maxOutputTokensForFeature(feature: string): number {
return feature === "framework_extract" ? 4096 : 1600;
}

export function estimateCostUsd(opts: {
provider: string;
inputTokens: number;
outputTokens: number;
}): number | null {
const rates = RATES[opts.provider] ?? null;
if (!rates) return null;
return Number(
((opts.inputTokens / 1_000_000) * rates.input + (opts.outputTokens / 1_000_000) * rates.output).toFixed(6),
);
}

export function buildAiEstimate(opts: {
feature: string;
messages: ChatMessage[];
dataClassesSent?: string[];
}) {
const cfg = getAiConfig();
const provider =
cfg.localOnly || cfg.provider === "ollama" ? "ollama" : (cfg.provider as string);
const model =
opts.feature === "evidence_digest"
? cfg.modelDigest
: cfg.modelDraft || (provider === "cursor" ? "composer-2.5" : "claude-sonnet-4-5");
const inputTokensApprox = estimateTokensFromMessages(opts.messages);
const outputTokensMax = maxOutputTokensForFeature(opts.feature);
const costUsdApprox = estimateCostUsd({
provider,
inputTokens: inputTokensApprox,
outputTokens: outputTokensMax,
});
return {
feature: opts.feature,
provider,
model: model || "unknown",
inputTokensApprox,
outputTokensMax,
costUsdApprox,
localOnly: Boolean(cfg.localOnly || provider === "ollama"),
dataClassesSent: opts.dataClassesSent ?? [],
note:
costUsdApprox == null
? "Token estimate only — Cursor/Ollama pricing is not modeled."
: "Approximate list-price estimate; actual bill depends on provider discounts and true tokenizer.",
};
}

export type { AiFeature };
25 changes: 25 additions & 0 deletions apps/api/src/bias.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { runBiasToneLint } from "../src/bias.js";

describe("bias tone lint", () => {
it("flags absolutist language", () => {
const findings = runBiasToneLint("They always ship and never miss.");
assert.ok(findings.some((f) => f.id === "absolutes"));
});

it("flags dense superlatives", () => {
const findings = runBiasToneLint("A brilliant genius and amazing rockstar ninja.");
assert.ok(findings.some((f) => f.id === "praise_density"));
});

it("flags stereotype-risk phrasing", () => {
const findings = runBiasToneLint("Can be abrasive and a cultural fit concern in meetings.");
assert.ok(findings.some((f) => f.id === "stereotype_risk"));
});

it("notes missing citations", () => {
const findings = runBiasToneLint("Solid contributor across the board this cycle.");
assert.ok(findings.some((f) => f.id === "no_citations"));
});
});
Loading