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
2 changes: 1 addition & 1 deletion .claude/commands/practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ raw = (frontOverall * 0.60) + (backOverall * 0.40)
overall = roundGrade(min(raw, lowestSubgrade + 1))
```

- Card detection uses Haiku (cheapest), subgrades use configured model
- Card detection uses Sonnet (reliable vision), subgrades use configured model
- `gradeSubgrade` receives pre-built image blocks (not URLs) — use `imageBlockFromUrl()` or `imageBlockFromBase64()`
- `cropCorners()` accepts URL or Buffer
- Back-only subgrades skipped when no back image — front score substituted
Expand Down
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ EBAY_CLIENT_SECRET=
# LLM grading — use one or the other when --grade-mode llm
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
TOGETHER_API_KEY=

# PSA pop report — no key needed. Uses api.psacard.com/publicapi (100 req/day anonymous, cached 24h).
# For a higher-quota key email collectors-apis@collectors.com and set PSA_AUTH_TOKEN here.
Expand Down
2 changes: 1 addition & 1 deletion lib/data/card-database.js
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ function resolveSetName(code) {
return SET_NAME_MAP[code] || tcgdexSetMeta.get(code)?.name || code;
}

function deriveEra(setCode) {
export function deriveEra(setCode) {
if (/^(sv\d|m\d)/.test(setCode)) return "Scarlet & Violet";
if (/^swsh/.test(setCode)) return "Sword & Shield";
if (/^sm/.test(setCode)) return "Sun & Moon";
Expand Down
11 changes: 8 additions & 3 deletions lib/grading/grading.js
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ function clampConf(n) {
return Math.min(1, Math.max(0, x));
}

function validateAndShape(provider, mode, o, raw) {
export function validateAndShape(provider, mode, o, raw) {
const overall = clampOverall(o.overall);
const centering = clampSub(o.centering);
const corners = clampSub(o.corners);
Expand Down Expand Up @@ -605,8 +605,13 @@ export async function gradeDetailedLLM(frontUrl, backUrl, config, extraImages =
const apiKey = process.env.ANTHROPIC_API_KEY;
let detectTokens = { input: 0, output: 0 };

const detectJobs = [detectAndCropCard(frontUrl, apiKey, "claude-haiku-4-5-20251001")];
if (hasBack) detectJobs.push(detectAndCropCard(backUrl, apiKey, "claude-haiku-4-5-20251001"));
const togetherKey = process.env.TOGETHER_API_KEY;
const detectOpts = togetherKey
? { provider: "together", togetherKey }
: {};
const detectModel = togetherKey ? null : "claude-sonnet-4-6";
const detectJobs = [detectAndCropCard(frontUrl, apiKey, detectModel, detectOpts)];
if (hasBack) detectJobs.push(detectAndCropCard(backUrl, apiKey, detectModel, detectOpts));
const [frontDetect, backDetect] = await Promise.all(detectJobs);

const frontBlock = frontDetect.cropped
Expand Down
65 changes: 51 additions & 14 deletions lib/grading/preprocessing.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,19 @@

Respond ONLY with valid JSON, no markdown.`;

export async function detectAndCropCard(imageUrl, apiKey, model) {
if (!apiKey) return { imageUrl, cropped: false };
export function parseAnthropicResponse(data) {
const text = (data?.content || []).map(b => b.type === "text" ? b.text : "").join("");
const usage = data?.usage || {};
return { text, tokens: { input: usage.input_tokens || 0, output: usage.output_tokens || 0 } };
}

await validateImageUrl(imageUrl);
const imgRes = await axios.get(imageUrl, {
responseType: "arraybuffer",
timeout: 15_000,
maxRedirects: 5,
});
const imgBuf = Buffer.from(imgRes.data);
const { width, height } = await sharp(imgBuf).metadata();
if (!width || !height) return { imageUrl, cropped: false };
export function parseTogetherResponse(data) {
const text = data?.choices?.[0]?.message?.content || "";
const usage = data?.usage || {};

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.
Comment on lines +54 to +58
return { text, tokens: { input: usage.prompt_tokens || 0, output: usage.completion_tokens || 0 } };
}

async function callDetectAnthropicApi(imageUrl, apiKey, model) {
const res = await axios.post("https://api.anthropic.com/v1/messages", {
model: model || "claude-sonnet-4-6",
max_tokens: 100,
Expand All @@ -74,10 +74,47 @@
headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" },
timeout: 30_000,
});
return parseAnthropicResponse(res.data);
}

const text = (res.data?.content || []).map(b => b.type === "text" ? b.text : "").join("");
const usage = res.data?.usage || {};
const tokens = { input: usage.input_tokens || 0, output: usage.output_tokens || 0 };
async function callDetectTogetherApi(imageBase64, apiKey, model) {
const res = await axios.post("https://api.together.xyz/v1/chat/completions", {
model: model || "zai-org/GLM-4.6V-Flash",
max_tokens: 100,
messages: [{
role: "user",
content: [
{ type: "image_url", image_url: { url: `data:image/jpeg;base64,${imageBase64}` } },
{ type: "text", text: DETECT_PROMPT },
],
}],
}, {
headers: { Authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
timeout: 30_000,
});
return parseTogetherResponse(res.data);
}

export async function detectAndCropCard(imageUrl, apiKey, model, opts = {}) {
if (!apiKey) return { imageUrl, cropped: false };

await validateImageUrl(imageUrl);
const imgRes = await axios.get(imageUrl, {
responseType: "arraybuffer",
timeout: 15_000,
maxRedirects: 5,
});
const imgBuf = Buffer.from(imgRes.data);
const { width, height } = await sharp(imgBuf).metadata();
if (!width || !height) return { imageUrl, cropped: false };

let text, tokens;
if (opts.provider === "together" && opts.togetherKey) {
const b64 = imgBuf.toString("base64");
({ text, tokens } = await callDetectTogetherApi(b64, opts.togetherKey, opts.togetherModel));
} else {
({ text, tokens } = await callDetectAnthropicApi(imageUrl, apiKey, model));
}

let bounds;
try {
Expand Down
2 changes: 1 addition & 1 deletion lib/grading/psa.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function difficultyLabel(pct) {
return "Easy";
}

function buildSignal({ pop10, pop9, popTotal }) {
export function buildSignal({ pop10, pop9, popTotal }) {
const psa10Chance = pop10 != null && popTotal ? (pop10 / popTotal) * 100 : null;
const psa9to10 = pop9 != null && pop10 ? pop9 / pop10 : null;
return {
Expand Down
1 change: 1 addition & 0 deletions terraform/secrets.tf
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ locals {
"CASECOMP_JWT_SECRET",
"GOOGLE_OAUTH_CLIENT_ID",
"CASECOMP_ADMIN_SUB",
"TOGETHER_API_KEY",
]
}

Expand Down
Loading
Loading