feat(atlascloud): support Atlas smart classifier

This commit is contained in:
lucaszhu-hue
2026-06-09 11:35:54 +08:00
parent 3f61ae58ef
commit 1d4b071042
6 changed files with 244 additions and 25 deletions
+26 -1
View File
@@ -10,6 +10,17 @@
</p>
<p align="center">
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=referral&utm_campaign=design-extract">
<img src="./assets/atlas-cloud-logo.svg" alt="Atlas Cloud" width="220">
</a>
</p>
<p align="center">
Powered by <a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=referral&utm_campaign=design-extract">Atlas Cloud</a> for OpenAI-compatible <code>--smart</code> classification.
For AI coding workflows, try the <a href="https://www.atlascloud.ai/console/coding-plan?utm_source=github&utm_medium=referral&utm_campaign=design-extract">Atlas Cloud coding plan</a>.
</p>
---
<p align="center">
@@ -51,6 +62,21 @@ npm i -g designlang # global
npx skills add Manavarya09/design-extract # as an agent skill (40+ agents)
```
## Atlas Cloud for `--smart`
`designlang --smart` can now use Atlas Cloud through its OpenAI-compatible chat API, so low-confidence classifiers can stay zero-dependency while routing to Atlas-hosted models.
```bash
export ATLASCLOUD_API_KEY="<atlascloud-api-key>"
export ATLASCLOUD_MODEL="deepseek-ai/deepseek-v4-pro"
npx designlang https://stripe.com --smart
```
- Atlas Cloud envs: `ATLASCLOUD_API_KEY`, `ATLASCLOUD_MODEL`, optional `ATLASCLOUD_API_BASE`
- Alias envs also work: `ATLAS_CLOUD_API_KEY`, `ATLAS_CLOUD_MODEL`, `ATLAS_CLOUD_API_BASE`
- Existing `OPENAI_API_KEY` and `ANTHROPIC_API_KEY` flows keep working unchanged
## What you get
Each run writes 17+ files to `./design-extract-output/`. The headline outputs:
@@ -285,4 +311,3 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). PRs welcome!
[MIT](LICENSE) - Manav Arya Singh
+55
View File
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1224 792">
<defs>
<style>
.cls-1 {
clip-path: url(#clippath);
}
.cls-2 {
fill: none;
}
.cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-4 {
clip-path: url(#clippath-1);
}
.cls-5 {
clip-path: url(#clippath-2);
}
.cls-3 {
fill: #000;
}
</style>
<clipPath id="clippath">
<rect class="cls-2" x="20.73" y="315.14" width="1182.55" height="161.72"/>
</clipPath>
<clipPath id="clippath-1">
<rect class="cls-2" x="20.73" y="315.14" width="1182.55" height="161.72"/>
</clipPath>
<clipPath id="clippath-2">
<rect class="cls-2" x="20.73" y="315.14" width="1182.55" height="161.72"/>
</clipPath>
</defs>
<g class="cls-1">
<path class="cls-3" d="M104.38,315.14L20.73,476.86c38.73-15.32,70.03-17.6,98.12-16.26l-12.85-28.4c-5.63-.57-21.86-.57-29.57,1.45l27.95-62.14s42.06,91.31,42.13,91.32c8.09,1.27,29.8,8.55,41.52,14.03l-83.66-161.72Z"/>
</g>
<polygon class="cls-3" points="207.87 323.37 207.87 361.57 189.68 361.57 189.68 381.76 207.87 381.76 207.87 471.35 234.07 471.35 234.07 381.76 252.87 381.76 252.87 361.57 234.07 361.57 234.07 323.37 207.87 323.37"/>
<rect class="cls-3" x="268.07" y="323.37" width="26.19" height="147.98"/>
<g class="cls-4">
<path class="cls-3" d="M340.48,416.95c0,13.6,9.79,33.39,33.39,33.39,14.6,0,24.2-7.6,29.2-17.59,2.6-4.8,3.8-10,4.2-15.4.2-5.2-.8-10.6-3-15.4-4.6-10.4-14.8-19.4-30.6-19.4-21.2,0-33.19,17.2-33.19,34.19v.2ZM433.26,471.35h-26.19v-15.8c-7,12.8-21.6,19.2-36.99,19.2-35.2,0-55.8-27.4-55.8-58.4,0-34.6,25-58.19,55.8-58.19,20,0,32.19,10.6,36.99,19.4v-16h26.19v109.78Z"/>
<path class="cls-3" d="M506.87,391.16c-.2-5.8-3.2-11.8-13.4-11.8-8.8,0-12.99,5.6-12.99,11,.2,7,8.2,10.79,18.6,13.8,17.6,4.8,36.6,10,36.8,33.59.4,22.6-18.6,36.99-41.59,36.99-17,0-40-8.8-41.99-35.39h26.19c1,11.8,11.2,14.2,16.2,14.2,8.2,0,15.2-5.4,15-12.8,0-9.4-8.2-12-28.4-18.99-14.8-4.6-26.79-12.8-26.99-28.6,0-21.6,18.19-35,39.39-35,14.8,0,36.8,6.6,39,33h-25.8Z"/>
<path class="cls-3" d="M760.26,421.76c-9,30.59-37.8,52.39-72.39,52.39-45.19,0-77.59-35.8-77.59-76.99s30.59-76.58,77.39-76.58c42.59.2,66.79,31.59,72.59,53.19h-31.4c-4.6-10.19-17.8-26.79-41.39-26.99-28.8,0-47.39,24.2-47.39,50.39s19.4,50.79,47.6,50.79c26.39,0,38.39-20.8,41.19-26.19h31.4Z"/>
</g>
<rect class="cls-3" x="779.68" y="323.37" width="26.19" height="147.98"/>
<g class="cls-5">
<path class="cls-3" d="M852.09,416.56c0,19.2,14.59,33.39,32.59,33.39s32.4-14.2,32.4-33.6-14.4-33.59-32.4-33.59-32.59,14.2-32.59,33.59v.2ZM825.89,416.15c0-28,20.6-57.79,58.79-57.79s58.79,29.79,58.79,57.99-20.59,58.19-58.79,58.19-58.79-29.79-58.79-58.19v-.2Z"/>
<path class="cls-3" d="M1064.87,471.35h-24.6v-14.6c-4.6,8.6-14.4,18.2-34.19,18-36-.2-42.79-27.6-42.79-46.8v-66.39h26.19v59.79c0,16.2,5.4,28.99,23.99,28.8,18.8,0,25.2-12.4,25.2-28v-60.59h26.2v109.78Z"/>
<path class="cls-3" d="M1111.28,416.35c0,18.6,13.4,33.8,33.59,33.8,9.6,0,18.2-3.8,24.4-10,6.2-6.2,9.8-14.8,9.39-24.4,0-9.6-3.8-17.8-9.8-23.6-6-6-14.39-9.6-23.79-9.6-21.4,0-33.8,16.4-33.8,33.59v.2ZM1203.27,471.35h-25v-15.8c-4.8,8.2-15.2,19.2-37.6,19.2-33.79,0-55.59-26.4-55.59-58.4,0-35.59,25.4-58.19,55.19-58.19,21,0,32.8,11.19,36.8,17.59v-52.39h26.2v147.98Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

+1 -1
View File
@@ -112,7 +112,7 @@ program
.option('--tokens-legacy', 'Emit pre-v7 flat token JSON (backward compat)')
.option('--platforms <csv>', 'Additional platforms: web,ios,android,flutter,wordpress,all (web is always emitted)', 'web')
.option('--emit-agent-rules', 'Emit Cursor/Claude Code/generic agent rules')
.option('--smart', 'use optional LLM fallback when heuristic classifiers have low confidence (needs OPENAI_API_KEY or ANTHROPIC_API_KEY)')
.option('--smart', 'use optional LLM fallback when heuristic classifiers have low confidence (supports ATLASCLOUD_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY)')
.option('--pages <n>', 'crawl N canonical pages (pricing/docs/blog/about/product) in addition to the homepage', parseInt)
.option('--no-prompts', 'skip writing the prompt-pack directory')
.option('--no-design-md', 'skip writing the agent-native DESIGN.md (single-file, 8-section, YAML front matter)')
+9 -1
View File
@@ -35,7 +35,15 @@ Useful flags the user may pass via `$ARGUMENTS`:
| `--depth <n>` | crawl N additional canonical pages |
| `--dark` | also extract dark mode |
| `--platforms ios,android,flutter,wordpress` | multi-platform emitters |
| `--smart` | LLM fallback for low-confidence classifiers (needs `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`) |
| `--smart` | LLM fallback for low-confidence classifiers (supports `ATLASCLOUD_API_KEY`, `OPENAI_API_KEY`, or `ANTHROPIC_API_KEY`) |
| `--cookie-file ./session.json` | authenticated extraction |
Full reference: https://github.com/Manavarya09/design-extract#full-cli-reference
Atlas Cloud quick setup for `--smart`:
```bash
export ATLASCLOUD_API_KEY="<atlascloud-api-key>"
export ATLASCLOUD_MODEL="deepseek-ai/deepseek-v4-pro"
npx designlang https://stripe.com --smart
```
+54 -22
View File
@@ -1,7 +1,8 @@
// Optional LLM fallback for low-confidence classifications. No SDK deps — we
// hit the OpenAI or Anthropic REST API directly via global fetch. Runs only
// when the user passes --smart AND an API key is available in env. Silently
// no-ops otherwise so the core extractor stays zero-config.
// hit the Anthropic REST API or an OpenAI-compatible chat endpoint directly via
// global fetch. Runs only when the user passes --smart AND an API key is
// available in env. Silently no-ops otherwise so the core extractor stays
// zero-config.
//
// Consumers call `refineWithSmart({ pageIntent, sectionRoles, materialLanguage,
// componentLibrary }, digest)` — we only hit the network for fields where
@@ -19,9 +20,38 @@ const TASKS = {
},
};
function detectProvider() {
if (process.env.ANTHROPIC_API_KEY) return 'anthropic';
if (process.env.OPENAI_API_KEY) return 'openai';
function trimTrailingSlash(url) {
return url ? url.replace(/\/+$/, '') : url;
}
export function resolveSmartProviderConfig(env = process.env) {
if (env.ANTHROPIC_API_KEY) {
return {
provider: 'anthropic',
apiKey: env.ANTHROPIC_API_KEY,
model: env.DESIGNLANG_MODEL || 'claude-haiku-4-5-20251001',
};
}
const atlasApiKey = env.ATLASCLOUD_API_KEY || env.ATLAS_CLOUD_API_KEY;
if (atlasApiKey) {
return {
provider: 'atlascloud',
apiKey: atlasApiKey,
baseUrl: trimTrailingSlash(env.ATLASCLOUD_API_BASE || env.ATLAS_CLOUD_API_BASE || 'https://api.atlascloud.ai/v1'),
model: env.DESIGNLANG_MODEL || env.ATLASCLOUD_MODEL || env.ATLAS_CLOUD_MODEL || 'deepseek-ai/deepseek-v4-pro',
};
}
if (env.OPENAI_API_KEY) {
return {
provider: 'openai',
apiKey: env.OPENAI_API_KEY,
baseUrl: trimTrailingSlash(env.OPENAI_API_BASE || env.OPENAI_BASE_URL || 'https://api.openai.com/v1'),
model: env.DESIGNLANG_MODEL || 'gpt-4o-mini',
};
}
return null;
}
@@ -44,16 +74,16 @@ function buildDigest({ rawData, design, pageIntent }) {
].join('\n\n');
}
async function callAnthropic(system, user) {
async function callAnthropic(config, system, user) {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': process.env.ANTHROPIC_API_KEY,
'x-api-key': config.apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: process.env.DESIGNLANG_MODEL || 'claude-haiku-4-5-20251001',
model: config.model,
max_tokens: 200,
system,
messages: [{ role: 'user', content: user }],
@@ -65,15 +95,15 @@ async function callAnthropic(system, user) {
return text;
}
async function callOpenAI(system, user) {
const res = await fetch('https://api.openai.com/v1/chat/completions', {
async function callOpenAICompatible(config, system, user) {
const res = await fetch(`${config.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
'authorization': `Bearer ${config.apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify({
model: process.env.DESIGNLANG_MODEL || 'gpt-4o-mini',
model: config.model,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
@@ -82,14 +112,14 @@ async function callOpenAI(system, user) {
response_format: { type: 'json_object' },
}),
});
if (!res.ok) throw new Error(`openai ${res.status}`);
if (!res.ok) throw new Error(`${config.provider} ${res.status}`);
const json = await res.json();
return json.choices?.[0]?.message?.content || '';
}
async function callLLM(provider, system, user) {
if (provider === 'anthropic') return callAnthropic(system, user);
return callOpenAI(system, user);
async function callLLM(config, system, user) {
if (config.provider === 'anthropic') return callAnthropic(config, system, user);
return callOpenAICompatible(config, system, user);
}
function parseJsonLoose(text) {
@@ -101,8 +131,10 @@ function parseJsonLoose(text) {
export async function refineWithSmart({ enabled, rawData, design, pageIntent, sectionRoles, materialLanguage, componentLibrary }) {
if (!enabled) return { applied: false, reason: 'disabled' };
const provider = detectProvider();
if (!provider) return { applied: false, reason: 'no API key (set OPENAI_API_KEY or ANTHROPIC_API_KEY)' };
const providerConfig = resolveSmartProviderConfig();
if (!providerConfig) {
return { applied: false, reason: 'no API key (set OPENAI_API_KEY, ATLASCLOUD_API_KEY, or ANTHROPIC_API_KEY)' };
}
const digest = buildDigest({ rawData, design, pageIntent });
const updates = {};
@@ -118,13 +150,13 @@ export async function refineWithSmart({ enabled, rawData, design, pageIntent, se
if (!spec) continue;
const user = `Digest:\n${digest}\n\nCurrent heuristic result:\n${JSON.stringify(current)}\n\nRespond with the requested JSON.`;
try {
const raw = await callLLM(provider, spec.system, user);
const raw = await callLLM(providerConfig, spec.system, user);
const parsed = parseJsonLoose(raw);
if (parsed) updates[task] = { ...parsed, smart: true, provider };
if (parsed) updates[task] = { ...parsed, smart: true, provider: providerConfig.provider };
} catch (e) {
errors.push(`${task}: ${e.message}`);
}
}
return { applied: true, provider, updates, errors };
return { applied: true, provider: providerConfig.provider, updates, errors };
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { refineWithSmart, resolveSmartProviderConfig } from '../src/classifiers/smart.js';
describe('resolveSmartProviderConfig', () => {
it('prefers Anthropic when present', () => {
const config = resolveSmartProviderConfig({
ANTHROPIC_API_KEY: 'anthropic-key',
ATLASCLOUD_API_KEY: 'atlas-key',
OPENAI_API_KEY: 'openai-key',
});
assert.deepEqual(config, {
provider: 'anthropic',
apiKey: 'anthropic-key',
model: 'claude-haiku-4-5-20251001',
});
});
it('resolves Atlas Cloud with default base URL and model', () => {
const config = resolveSmartProviderConfig({
ATLASCLOUD_API_KEY: 'atlas-key',
});
assert.deepEqual(config, {
provider: 'atlascloud',
apiKey: 'atlas-key',
baseUrl: 'https://api.atlascloud.ai/v1',
model: 'deepseek-ai/deepseek-v4-pro',
});
});
it('accepts Atlas Cloud alias env vars', () => {
const config = resolveSmartProviderConfig({
ATLAS_CLOUD_API_KEY: 'atlas-key',
ATLAS_CLOUD_API_BASE: 'https://atlas.example.com/v1/',
ATLAS_CLOUD_MODEL: 'deepseek-ai/deepseek-v4-flash',
});
assert.deepEqual(config, {
provider: 'atlascloud',
apiKey: 'atlas-key',
baseUrl: 'https://atlas.example.com/v1',
model: 'deepseek-ai/deepseek-v4-flash',
});
});
it('resolves OpenAI-compatible base URL overrides for OpenAI', () => {
const config = resolveSmartProviderConfig({
OPENAI_API_KEY: 'openai-key',
OPENAI_BASE_URL: 'https://proxy.example.com/v1/',
DESIGNLANG_MODEL: 'gpt-4.1-mini',
});
assert.deepEqual(config, {
provider: 'openai',
apiKey: 'openai-key',
baseUrl: 'https://proxy.example.com/v1',
model: 'gpt-4.1-mini',
});
});
});
describe('refineWithSmart', () => {
it('returns a helpful reason when no provider is configured', async () => {
const original = {
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
ATLASCLOUD_API_KEY: process.env.ATLASCLOUD_API_KEY,
ATLAS_CLOUD_API_KEY: process.env.ATLAS_CLOUD_API_KEY,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
};
delete process.env.ANTHROPIC_API_KEY;
delete process.env.ATLASCLOUD_API_KEY;
delete process.env.ATLAS_CLOUD_API_KEY;
delete process.env.OPENAI_API_KEY;
try {
const result = await refineWithSmart({
enabled: true,
rawData: {},
design: {},
pageIntent: {},
sectionRoles: {},
materialLanguage: {},
componentLibrary: {},
});
assert.equal(result.applied, false);
assert.match(result.reason, /ATLASCLOUD_API_KEY/);
} finally {
for (const [key, value] of Object.entries(original)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
});