Uh oh!
There was an error while loading. Please reload this page.
') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })();
There was an error while loading. Please reload this page.
参赛项目名称
第二时钟(Second Clock)—— 你的药,开封后还能用多久
在线体验:https://2c.klinik.ren
团队 / 作者
inoichi(个人参赛)
我做了什么
一瓶印着「2027 年到期」的多剂量滴眼液,今天开封之后,按说明书或《中国药典》对眼用制剂的通则,可用期限可能只剩 4 周。药盒上的日期是第一个时钟,开封后的期限是第二个,却常常没有地方替你倒计时。
于是我做了《第二时钟》:记录两个日期,提醒更早到来的那个停用日。
真正难的不是倒计时,而是不让视觉模型把猜错的药名和日期悄悄写成事实。在提示词里补上反例之后错误仍会复现,所以我没有把提示词或模型自报的置信度当成安全边界,而是把自动化限制在三层检查里:
下面是功能本身:
内置期限整理自公开药品说明书与《中国药典》2020 年版相关通则;无法逐条核到说明书原文的项目只标为「参考值」。这些数值用于时间提醒,不替代具体产品说明书或药师判断。
使用的工具
/compatible-mode/v1/chat/completions)用法:药盒照片以 base64 传入,配合结构化提示词,要求模型返回严格 JSON 数组(通用名 / 商品名 / 规格 / 剂型 / 印刷有效期 / 有效期来源 / 置信度)。前端不持有任何密钥,请求经自有服务器的同源代理转发,密钥只存在服务端环境变量。
为什么最后选 Qwen-VL:三张样品的对照
这不是通用模型评测,只记录同一套提示词、同三张真实照片下的结果;样本很小,换图片或参数可能得到不同结果。(注:出现下表这些问题的是免费档的 GLM-4.6V-Flash;现在做交叉核对的是付费档的 GLM-4.6V,两者不是同一个模型。选付费档是因为免费档实测被限流得厉害,验证一旦频繁失败这个功能就成了摆设。)
confidence=highconfidence=highconfidence=low,未触发药库自动预填在这组三图测试中,Qwen-VL 对两张有文字样品的结果更符合实物,因此成为当前版本的视觉模型。第三张也说明模型输出不能直接当作事实:代码会拦截低置信度药名,不拿它查询药库或自动填写期限,并要求用户核对包装。这个结果只用于本项目选型,不代表对两种模型总体能力的排序。
效果展示
在线体验(打开即用,无需注册):https://2c.klinik.ren
使用手册(含评委用「快速体验路径」):https://2c.klinik.ren/manual.html
开发过程报告(七条缺陷档案,含作者自己犯的那次):https://2c.klinik.ren/making.html
30 秒看核心:在「产品名称」输入
红霉素眼膏→ 开封后可用天数自动填成 28 天 → 点「确认添加」→ 展开页面最下方「⏱ 模拟时间推移」,依次点+20天→+1天→+7天。卡片会由绿转黄再转红,今天到期时顶部出现红色提醒。竞品分析:这是新东西,还是旧功能的叠加?
自己提的主张自己先去证伪。做了两条独立检索链路:Bing/搜狗/DuckDuckGo 三引擎 54 组查询 + App Store 页面原文抓取;以及 Codex CLI 独立联网调研(24 个条目 / 29 个来源链接)。两边一致处采信,不一致处按更保守的一方表述。
先说不利结论:「首创开封后计时」站不住
确实有产品做了这件事:
但形态不同:给「格子」,还是给「答案」
前两个给的是一个让你自己填天数的空格子——而「填多少」正是原始难题本身;安心药箱的离线药品库是 57 款。后两个直接给答案,但药种极窄(几种减重针、只做眼药水)。
竞品全景:五个类别
空隙在于:「家里这盒开过封的药还能用到哪天」,A 给格子不给答案、B 不管、C 不管、D 只服务药房。
三个差异点的裁定
最难反驳的一点,也写出来
数据量不是壁垒——1832 条谁都能用 AI 补上。 而且本作品的 1832 条里相当一部分是剂型通用参考值,不是逐条核到说明书原文(产品内已标「参考值」)。数据量的领先不等于精度的领先。
能站住的回应是:真正补不上的不是数据,是把「不知道」变成可计算的量、并在算出不知道时拒绝作答这套机制。而它与数据量恰好反向——库越大,同名多剂型的撞键越多,问题越严重:1363 条时这个缺陷藏着,1832 条时才暴露出 27 个撞键。用 AI 生成更多数据的人只会更快撞上它,不会自动获得解法。
踩坑记录(可选)
1. 守卫必须覆盖所有入口,也要覆盖异步等待期
最初我只在识别结果那一屏拦截低置信度药名。后来发现:用户点「修改信息」转到手动表单后,药名框失焦会再次无条件触发药库预填;交叉核对结论返回之前,「确认并添加」也照样能点。闸都在,多点一下就绕过去了。
后来把可信度判断下沉进预填函数本身,并在核对结论返回前禁用全部提交入口。守卫装在调用处是不够的,要装进被调用的那个函数里。
2. 日期证据必须能被代码核对
模型曾把注册批准号旁边的日期当成有效期;对着一盒克罗地亚语包装,它甚至编出一段中文证据「有效期至」——那五个汉字不可能出现在那个盒面上,而它同时自称置信度高,3/3 稳定复现。
提示词加固能减少但杜绝不了。所以改成让它交出可被代码核对的东西:逐字证据 + 包装语种。代码检查证据语种是否与包装语种矛盾、它声称的年份是否真的出现在证据原文里;核不上就丢弃该日期。在手头的 6 个样品上没有误拦,但样本很小,只能说明当前回归结果。
3. 数据存在不等于功能生效
药库里有 1744 条储存与使用注意事项,但收尾审计时才发现渲染条件写错,它们几乎从不显示——「84 消毒液不可与洁厕灵混用」这类提醒全都躺在数据里睡觉。它最坏的地方是让维护者以为这一面已经防住了。
现已单独渲染,并且只在药名精确命中时出现:模糊命中的药名本身带着不确定性,不该用确定的口吻转述一条安全提醒。
4. 一个商品名可以是好几种药
查表用的是
Map,建索引时写的是「这个键还没有就存进去」——先到先得。看起来没问题,直到发现一个商品名合法地覆盖多个剂型:「希刻劳」既是头孢克洛干混悬剂,也是头孢克洛胶囊。前者加水冲配后要冷藏、14 天内用完;后者按外盒印刷效期、常温存放。用户打「希刻劳」,拿到的是数组里排在前面的那一条,而他完全看不出来自己拿到的是另一个剂型的答案。全库扫下来这样的键有 27 个,「泰诺林」「可乐必妥」「兰美抒」「胃复安」都在里面。
修法不是删数据——那些别名都是真的。改成索引值收全部命中条目,命中多条时先比对它们的预填答案(开封天数 + 储存条件),一致才算等价;不一致就不预填,并在提示下面就地升起一排剂型按钮,让用户照药盒点一个。「剂型」这个字段平时是选填,只在这一刻变成必填。
之所以做成让用户点剂型、而不是原先那句「请补全完整通用名」:剂型就印在药盒上,照抄一眼的事;而完整通用名他要是知道,一开始就不会去打商品名了。把消歧的举证责任放到唯一掌握答案的人手里,而不是放到他答不上来的那个问题上。
5. 「开封」指的是拆盒还是撕袋
药库里有一批单剂量小袋装的颗粒剂,说明写着「拆袋后当次冲服完,不留存」,而开封天数字段是空的(按印刷效期)。审计报上来说这是自相矛盾:界面显示「无需开封计时」,说明却要求立即使用,建议把天数改成 1 天。
改完之后才想明白这是错的。用户填的「开封日期」,填的是他拆盒那天。 一盒二十袋的颗粒,拆盒后剩下十九袋仍是独立密封的,好到印刷效期为止;标 1 天的话,这张卡第二天就变红说过期,用户会扔掉一整盒好药。
两个说法都对,只是主语不同:一个说的是盒,一个说的是袋。字段只有一个,就必须先想清楚它代表谁的动作。最后保留「按印刷效期」,把两件事在文案里拆开讲:
顺着这条又发现剂型兜底规则里,「颗粒剂」和「口服液」的默认值都写着 1 天——意味着任何一个没被收录的同类产品,都会被填成「明天过期」。一并改掉了。
写下来是想说:审计指出的矛盾是真的,但它给的修法不一定对。「哪个数字对」的前提是「这个字段到底在描述谁」,而后者不在代码里,得回到用户的动作上去问。
6. 我自己用正则批量改医疗数据,错了 12/19
发现一批「未开封需冷藏」的药,说明里其实写着开封后可以转室温,而界面只显示一个储存状态,容易让人以为开封后还得继续冷藏。于是我写了条规则:凡
storage是冷藏、且说明里出现「室温」或「常温」字样的,统一在提示里追加一句「开封启用后可转室温存放」。改了 19 条。审计报回来说其中 7 条是反的。我自己核完发现是 12 条。
出问题的是这类:阿莫西林干混悬剂的说明是「干粉常温保存;配制后冷藏,7 天内服完」。正则匹配到了「常温」二字,于是追加了一句意思正好相反的话——等于告诉用户,冲好的抗生素混悬液可以放在室温下。另有五条是「未开封时允许短期室温外放」的宽限条款,被我写成了「开封后可转室温」,主语也错了。
正则读得到字符,读不到那句话在讲哪个阶段。而这批数据里,「常温」出现在句子的前半段还是后半段,决定的是完全相反的两件事。
全部撤回,逐条核实后只给确实成立的 7 条重新加;反向的那几条另外写明「未拆封的干粉按常温存放,加水冲配之后才需要冷藏——顺序别弄反」。
这是本项目里我自己犯的最危险的一个错,而它恰好印证了整个作品的主张:批量生成的东西必须过一道独立的核对,包括我自己批量生成的东西。
7. 「诺和盈」和「诺和泰」不是同一个产品
数据扩充后做了一次别名体检,发现降糖版司美格鲁肽的别名里挂着「诺和盈」和「Wegovy」——那是减重版的商品名。两者开封后可用天数分别是 42 天和 28 天。用户打「诺和盈」,拿到的是 42 天。
同类的还有九组:30R 胰岛素挂着「诺和灵50R」、「优泌乐25」挂着「优泌乐50」、口服补液盐 III 挂着 ORS I 与 ORS II(三个不同配方)。另有三个过泛别名更麻烦:「活菌制剂」「胰岛素瓶装」「滴剂型活菌」——这种词会把用户输入的任意同类药抓到一张错卡上。
摘掉别名之后补建了独立条目,各自给自己的数字。其中「口服补液盐散(I/II)」这个新名字又踩了一次坑:搜索前会做归一化(去掉括号与斜杠),
(I/II)归一化之后正好等于III,两条撞成同一个键。改名成「(I型或II型)」才躲开。归一化是为了容错,但它同时也在制造碰撞,而碰撞发生在哪两条身上,不看代码是想不出来的——只能全库跑一遍比对。
使用边界:本作品只根据用户录入的日期与参考规则做时间提醒,不判断药品质量、疗效或是否变质,也不构成用药建议。具体期限与储存要求以该产品说明书、包装标识及药师/医师意见为准;包装破损、储存异常或性状变化时,不应仅依据倒计时继续使用。