Import project sources

This commit is contained in:
2026-11-05 01:04:53 +08:00
commit 0de3879565
29 changed files with 3570 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
export type Field={name:string;type:'string'|'number'|'boolean'|'object'|'array';description:string;fields?:Field[]};
export class InputError extends Error{}
export function buildSchema(fields:Field[],depth=0):any{
if(!Array.isArray(fields)||!fields.length||fields.length>40||depth>5)throw new InputError('每层需要 1–40 个字段,最多嵌套 5 层');
const properties:Record<string,any>={};
for(const f of fields){
if(!f||typeof f.name!=='string'||!/^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/.test(f.name)||Object.hasOwn(properties,f.name)||['__proto__','constructor','prototype'].includes(f.name))throw new InputError('字段名需唯一,仅用字母、数字和下划线,以字母或下划线开头');
if(typeof f.description!=='string'||f.description.length>2000)throw new InputError('字段描述必须是最多 2000 字符的文本');
let s:any;
if(f.type==='object')s=buildSchema(f.fields!,depth+1);
else if(f.type==='array')s={type:'array',items:f.fields?.length?buildSchema(f.fields,depth+1):{type:'string'}};
else if(['string','number','boolean'].includes(f.type))s={type:[f.type,'null']};
else throw new InputError('不支持的字段类型');
properties[f.name]={...s,description:f.description};
}
return {type:'object',properties,required:Object.keys(properties),additionalProperties:false};
}
export const sampleFields:Field[]=[{name:'patientName',type:'string',description:'患者姓名'},{name:'allergies',type:'array',description:'过敏史'},{name:'vitalSigns',type:'array',description:'按时间记录的生命体征',fields:[{name:'time',type:'string',description:'测量时间'},{name:'heartRate',type:'number',description:'心率,次/分钟'},{name:'bloodPressure',type:'string',description:'血压,mmHg'}]},{name:'medications',type:'array',description:'用药记录',fields:[{name:'name',type:'string',description:'药物名称'},{name:'dose',type:'string',description:'剂量及单位'}]}];
export const sampleText='入院记录\n患者姓名:陈晓雨\n过敏史:青霉素\n2026-11-02 09:00 心率 78 次/分,血压 120/80 mmHg。\n2026-11-02 11:00 心率 75 次/分,血压 118/78 mmHg。\n用药:氯化钠注射液 500 mL。';
+9
View File
@@ -0,0 +1,9 @@
import {mkdir,readFile,readdir} from 'node:fs/promises';
import path from 'node:path';
import {root,atomic,dir} from './store';
import {buildSchema,Field,InputError} from './schema';
export type SavedSchema={id:string;name:string;fields:Field[];schema:any;createdAt:string;updatedAt:string;version:number};
export function schemaPath(id:string){dir(id);return path.join(root,'schemas',id+'.json');}
export async function getSchema(id:string):Promise<SavedSchema>{return JSON.parse(await readFile(schemaPath(id),'utf8'));}
export async function listSchemas(){await mkdir(path.join(root,'schemas'),{recursive:true});const files=await readdir(path.join(root,'schemas'));return (await Promise.all(files.filter(f=>f.endsWith('.json')).map(f=>getSchema(f.slice(0,-5))))).sort((a,b)=>b.updatedAt.localeCompare(a.updatedAt));}
export async function putSchema(id:string,body:any,previous?:SavedSchema){if(typeof body?.name!=='string'||!body.name.trim()||body.name.length>120)throw new InputError('name 必须是 1–120 字符的名称');const schema=buildSchema(body.fields);const time=new Date().toISOString();const saved:SavedSchema={id,name:body.name.trim(),fields:body.fields,schema,createdAt:previous?.createdAt||time,updatedAt:time,version:(previous?.version||0)+1};await mkdir(path.join(root,'schemas'),{recursive:true});await atomic(schemaPath(id),saved);return saved;}
+16
View File
@@ -0,0 +1,16 @@
import {mkdir,readFile,writeFile,rename} from 'node:fs/promises';
import path from 'node:path';
import {randomUUID,timingSafeEqual} from 'node:crypto';
import type {NextApiRequest,NextApiResponse} from 'next';
import type {Field} from './schema';
export const root=path.resolve(process.env.EXTRACTOR_DATA_DIR||'.data');
export type Task={id:string;title:string;createdAt:string;fields:Field[];schema:any;document:{name:string;mime:string;size:number};status:'ready'|'completed'};
export type Table={name:string;columns:string[];rows:(string|number|boolean|null)[][]};
export function dir(id:string){if(!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(id))throw new Error('NOT_FOUND');return path.join(root,id);}
export async function readTask(id:string):Promise<Task>{return JSON.parse(await readFile(path.join(dir(id),'task.json'),'utf8'));}
export async function atomic(file:string,value:any){const tmp=file+'.'+randomUUID()+'.tmp';await writeFile(tmp,JSON.stringify(value,null,2));await rename(tmp,file);}
export async function saveTask(task:Task,bytes:Buffer){const d=dir(task.id);await mkdir(d,{recursive:true});await writeFile(path.join(d,'document'),bytes);await atomic(path.join(d,'task.json'),task);}
export function authorize(req:NextApiRequest,res:NextApiResponse){const token=process.env.EXTRACTOR_API_KEY;if(!token)return true;const a=Buffer.from(`Bearer ${token}`),b=Buffer.from(req.headers.authorization||'');if(a.length===b.length&&timingSafeEqual(a,b))return true;res.status(401).json({error:{code:'UNAUTHORIZED',message:'需要有效的 Bearer token'}});return false;}
export function fail(res:NextApiResponse,e:any){const missing=e.code==='ENOENT'||e.message==='NOT_FOUND';res.status(missing?404:500).json({error:{code:missing?'NOT_FOUND':'STORAGE_ERROR',message:missing?'任务不存在':'本地文件操作失败'}});}
export function links(id:string){return {schema:`/api/v1/tasks/${id}`,document:`/api/v1/tasks/${id}/document`,instructions:`/api/v1/tasks/${id}/instructions`,result:`/api/v1/tasks/${id}/result`};}
export function tablesFrom(data:Record<string,any>):Table[]{const tables:Table[]=[];const scalars=Object.entries(data).filter(([,v])=>v===null||typeof v!=='object');if(scalars.length)tables.push({name:'基本信息',columns:['字段','值'],rows:scalars});for(const [name,value] of Object.entries(data)){if(Array.isArray(value)&&value.length){if(value.every(v=>v!==null&&typeof v==='object'&&!Array.isArray(v))){const columns=Object.keys(value[0]);tables.push({name,columns,rows:value.map(v=>columns.map(c=>typeof v[c]==='object'&&v[c]!==null?JSON.stringify(v[c]):v[c]??null))});}else tables.push({name,columns:['值'],rows:value.map(v=>[typeof v==='object'&&v!==null?JSON.stringify(v):v])});}}return tables;}