Files
jev-match-game/src/services/jev.ts
T

183 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Jev 服务层 —— TypeSafe AI 的 System One 模型。
* REST: POST https://api.typesafe.ai/v1/systemone
* Header: Authorization: Bearer <TYPESAFE_API_KEY>
* 请求: { model: "jev-latest", state: <文本或JSON>, questions: { <id>: Question } }
*
* 以下 schema 按真实接口 curl 实测对齐(2026-10-03):
* - Question 的文本字段是 instructions;
* - choice 的 criteria 是 { 选项: 描述 } 的 map(选项数 ≤255);
* - score 的 criteria 是 2-10 级有序数组,索引从 0 开始(min=0, max=criteria.length-1);
* - score 答案是概率加权浮点值,可落在两级之间;legend 为 { 索引字符串: 等级描述 }。
*
* 实测响应样例:
* {"model":"jev-1.13.0","answers":{"risk":{"type":"score","score":1.46,"confidence":0.62,
* "legend":{"0":"非常保守",...},"probabilities":{"0":0.0,"1":0.55,...}},
* "hobby":{"type":"choice","choice":"outdoor","confidence":0.99,
* "probabilities":{"social":0.0,"outdoor":0.99,...}}},
* "usage":{"input_tokens":446,"output_tokens":57}}
*/
/** 是非概率问题 */
export interface NoulQuestion {
type: 'noul';
instructions: string;
}
/** 选择题(≤255 选项),criteria 为 { 选项: 描述 } 的 map */
export interface ChoiceQuestion {
type: 'choice';
instructions: string;
criteria: Record<string, string>;
}
/** 打分题,criteria 为 2-10 级有序数组(索引 0 起,由低到高) */
export interface ScoreQuestion {
type: 'score';
instructions: string;
criteria: string[];
}
export type JevQuestion = NoulQuestion | ChoiceQuestion | ScoreQuestion;
export interface JevRequest {
model: 'jev-latest';
state: unknown;
questions: Record<string, JevQuestion>;
}
export type JevAnswer =
| { type: 'noul'; probability: number; confidence: number }
| { type: 'choice'; choice: string; probabilities: Record<string, number>; confidence: number }
| {
type: 'score';
/** 概率加权浮点分数,范围 [0, criteria.length-1],可落在两级之间 */
score: number;
confidence: number;
legend: Record<string, string>;
probabilities: Record<string, number>;
};
export interface JevClient {
ask(state: unknown, questions: Record<string, JevQuestion>): Promise<Record<string, JevAnswer>>;
}
const API_URL = 'https://api.typesafe.ai/v1/systemone';
/**
* REST 实现,两种模式:
* - 代理模式(优先):传 proxyUrl,请求发到 `${proxyUrl}/api/jev`,key 在服务端,
* 前端不带 Authorization(见 server/ 子包);
* - 直连模式(fallback):传 apiKey,直连 api.typesafe.ai,浏览器可能遇到 CORS。
*/
export class HttpJevClient implements JevClient {
private apiKey?: string;
private proxyUrl?: string;
constructor(apiKey?: string, proxyUrl?: string) {
this.apiKey = apiKey;
this.proxyUrl = proxyUrl;
}
async ask(state: unknown, questions: Record<string, JevQuestion>): Promise<Record<string, JevAnswer>> {
const url = this.proxyUrl ? `${this.proxyUrl}/api/jev` : API_URL;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (!this.proxyUrl && this.apiKey) {
headers.Authorization = `Bearer ${this.apiKey}`;
}
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ model: 'jev-latest', state, questions } satisfies JevRequest),
});
if (!res.ok) {
throw new Error(`Jev API ${res.status}: ${await res.text()}`);
}
const body = (await res.json()) as { answers: Record<string, JevAnswer> };
return body.answers;
}
}
/** 简单确定性 hash(FNV-1a),Mock 用它让同一 (state, question) 永远得到同一答案。 */
function hash(input: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
/** 由 hash 生成 [0,1) 的确定性伪随机序列 */
function prng(seed: number, n: number): number[] {
const out: number[] = [];
let s = seed;
for (let i = 0; i < n; i++) {
s = hash(String(s) + ':' + i);
out.push(s / 0xffffffff);
}
return out;
}
/** 无 key 开发用:确定性伪随机返分(同一输入永远同一输出,输出形状与真实 API 对齐)。 */
export class MockJevClient implements JevClient {
async ask(state: unknown, questions: Record<string, JevQuestion>): Promise<Record<string, JevAnswer>> {
const stateKey = JSON.stringify(state) ?? '';
const answers: Record<string, JevAnswer> = {};
for (const [id, q] of Object.entries(questions)) {
const seed = hash(stateKey + '|' + id);
const confidence = Math.round((0.35 + prng(seed, 1)[0] * 0.6) * 100) / 100;
if (q.type === 'noul') {
answers[id] = { type: 'noul', probability: Math.round(prng(seed, 2)[1] * 100) / 100, confidence };
} else if (q.type === 'choice') {
const options = Object.keys(q.criteria);
const r = prng(seed, options.length);
const total = r.reduce((a, b) => a + b, 0) || 1;
const probabilities: Record<string, number> = {};
let choice = options[0];
let best = -1;
options.forEach((opt, i) => {
probabilities[opt] = Math.round((r[i] / total) * 100) / 100;
if (r[i] > best) {
best = r[i];
choice = opt;
}
});
answers[id] = { type: 'choice', choice, probabilities, confidence };
} else {
const r = prng(seed, q.criteria.length);
const total = r.reduce((a, b) => a + b, 0) || 1;
const probabilities: Record<string, number> = {};
const legend: Record<string, string> = {};
let score = 0;
q.criteria.forEach((level, i) => {
const p = r[i] / total;
probabilities[String(i)] = Math.round(p * 100) / 100;
legend[String(i)] = level;
score += i * p;
});
answers[id] = { type: 'score', score: Math.round(score * 100) / 100, confidence, legend, probabilities };
}
}
return answers;
}
}
let singleton: JevClient | null = null;
/** 有代理走代理,有 key 直连,两者都缺时降级 Mock 并提示。 */
export function getJevClient(): JevClient {
if (singleton) return singleton;
const proxyUrl = import.meta.env.VITE_JEV_PROXY_URL;
const key = import.meta.env.VITE_TYPESAFE_API_KEY;
if (proxyUrl) {
singleton = new HttpJevClient(undefined, proxyUrl);
} else if (key) {
singleton = new HttpJevClient(key);
} else {
console.warn('[jev] 未配置 VITE_JEV_PROXY_URL / VITE_TYPESAFE_API_KEY,降级为 MockJevClient(确定性伪随机返分)');
singleton = new MockJevClient();
}
return singleton;
}