本文是参加 1024 造物挑战的作品技术复盘。主角不是"某个工具",而是它的实现:如何在浏览器里,用一个单文件、零依赖、可离线的规则引擎,完成一套"可插拔评分标准 + 反刷分评分"的体系。
在线体验(公开可打开):https://proposal-checker.app.workbuddy.host/?sharecode=F4HFIUYZ2H
需求来自一个真实场景——教师对"课题申报书"做立项前自查。这类材料有三个硬约束:
这三条正好指向"离线规则引擎"而不是"调大模型 API"。于是技术路线定为:纯前端单文件 HTML,零依赖,全部计算在浏览器完成,没有任何网络请求。
代价也很明确:没有语义理解,只能做"结构化 + 启发式"检测。这是主动取舍,不是能力缺失。
标准解析层:把任意"评审标准"解析成可执行的评分树(可插拔)
评分引擎:证据句判定 + 加权打分 + 反刷分约束 + 逻辑一致性检查
渲染/导出层:报告渲染、意见书文本生成、本地存档对比
核心设计目标是"尺子可换":内置的默认标准只是示例,用户上传自己单位的标准,评分逻辑立刻切换。这让工具从"一把固定尺子"变成"一个可复用的评审模板引擎"。
评分标准被抽象成两层结构(维度 -> 子项):
{ dim: "内容与设计", max: 40, subs: [
{ name: "研究内容分解科学、重点难点明确", w: 12, kws: [...], binary: false }
]}支持两种输入格式:精确格式 维度名 | 子项名 | 权重;自由文本(自动识别维度标题、剥离序号、抓"N分"作权重)。
解析后最关键的一步是权重归一化到 100:
const all = dims.flatMap(d => d.sub);
const allHaveW = all.every(s => s.w != null && s.w > 0);
const factor = allHaveW ? (100 / all.reduce((a, s) => a + s.w, 0)) : 1;
let w = s.w != null ? s.w * factor : (100 / all.length);子项的匹配关键词是自动派生的:命中通用概念同义词库 + 从条目名里切 2–6 字片段。
const CONCEPT = {
"目标": ["研究目标","目标","旨在","目的","总目标","拟"],
"方法": ["思路","方法","技术路线","问卷","访谈","案例","实证","扎根"],
"创新": ["创新","突破","前沿","首次","新视角","填补","重构"]
};
function deriveKw(name){
const kws = new Set();
for(const key in CONCEPT) if(name.includes(key)) CONCEPT[key].forEach(k => kws.add(k));
name.split(/[::,,。、;;()()\s]+/).filter(t => t.length>=2 && t.length<=6).forEach(t => kws.add(t));
return [...kws];
}第一版评分就是最朴素的"关键词命中数 / 目标数"。上线前自测发现严重漏洞:把"创新、目标、内容、方法、成果、价值"这些词在一句话里各写一遍,分数能刷到 80+;纯堆词不展开也能拿 60 分。也就是说,一个"看起来什么都有、实际什么都没写"的稿子能骗过评分器。
解决方案是引入"证据句"判定——关键词必须落在有实质展开的句子里才算有效命中:
const ACTION = ["构建","建立","设计","验证","实证","检验","比较","测量","分析",
"界定","提出","采用","运用","基于","结合","梳理","评价","明确",
"形成","开展","实施","调查","访谈","问卷","案例","纳入","设置"];
function isEvidence(s){
return s.length >= 22 && ACTION.some(a => s.includes(a)); // 够长 + 有实义动作
}代入评分,并加一条全局硬约束:
function subScore(text, sub){
const sents = splitSentences(text); // 按 。!?\n;; 切句
const sentsLow = sents.map(s => s.toLowerCase());
let validHits = 0; // 只统计"有效命中"
for(const k of sub.kws){
const kl = k.toLowerCase();
if(sentsLow.some((sl, i) => sl.includes(kl) && isEvidence(sents[i]))) validHits++;
}
const evCount = sents.filter(s => isEvidence(s)).length; // 全文证据句总数
const raw = Math.min(validHits / Math.min(sub.kws.length, 3), 1);
let full = raw >= 0.7;
// 全局约束:全文证据句不足 2 句 —— 任何维度都不许判"达标"
if(!sub.binary && evCount < 2) full = false;
const frac = full ? 1 : 0.3 + 0.45 * raw;
return { score: Math.round(sub.w * frac) };
}实测效果(同一份作弊稿前后对比):
样本 | 旧版(朴素命中) | 新版(证据句约束) |
|---|---|---|
结构完整的正常稿 | 87 | 87(不误伤) |
一句话堆满所有关键词 | 80+ | 48 |
纯堆词、无任何展开 | 60 | 22 |
正常稿不误伤,作弊稿被压回原形——这是这次实现里最值钱的一处改动。
.docx 本质是个 zip。要在浏览器里离线读出文字且不引入任何库:从文件尾部倒序找 EOCD 签名 0x06054b50,拿到中央目录偏移与条目数;遍历中央目录取压缩方式、偏移、文件名;用浏览器原生的 DecompressionStream("deflate-raw") 解压;最后正则提取 <w:t> 里的文本。
async function inflate(raw, method){
if(method === 0) return raw; // 未压缩
if(method === 8){
const ds = new DecompressionStream("deflate-raw");
const stream = new Response(raw).body.pipeThrough(ds);
return await new Response(stream).arrayBuffer();
}
throw new Error("不支持的压缩方式");
}
const dv = new DataView(buf);
let eocd = -1;
for(let i = buf.byteLength - 22; i >= 0; i--){
if(dv.getUint32(i, true) === 0x06054b50){ eocd = i; break; }
}
const cdOffset = dv.getUint32(eocd + 16, true);全程在本地内存完成,申报书内容一个字节都不出浏览器——这正是这个场景能成立的前提。
除逐项打分外,还做了一层"跨段体检":用关键词共现 + 互斥判定抓四类硬伤——研究方法是否只说"问卷访谈"而无对象/工具;创新点是否只列未展开;研究目标与研究内容是否对应;预期成果与研究目标是否对应。另有参考文献时效性信号(正则抽取年份,判"偏少 / 近 3 年不足 / 达标")。
这个作品的工程判断可以浓缩成一句话:不是不会用大模型,是刻意不用——涉密场景要可解释、要可复现、要零成本,所以把领域标准固化成规则引擎,跑在用户自己的浏览器里。而"可插拔标准"这个设计,让它从"一个工具"变成"一个模板引擎":任何单位上传自己的评审表,评分逻辑立刻切换。
在线体验(公开可打开):https://proposal-checker.app.workbuddy.host/?sharecode=F4HFIUYZ2H
#WorkBuddy #1024程序员节 #前端 #架构设计 #规则引擎 #教育科研
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。