首页 文章 精选 留言 我的

精选列表

搜索[mark],共2150篇文章
优秀的个人博客,低调大师

HBase的Rowkey设计(mark)

在HBase中细节上的设计,最最最重要的就是我该选取什么做Rowkey,Rowkey的选择,最直接的影响就是对你之后分析数据的影响了。 Rowkey是不可分割的字节数,按照字典排序由低到高存储在表中。一个空的数组用来标识表空间的起始或结尾。 在设计HBase表时,Rowkey设计是最重要的事情,应该基于预期的访问模式来为Rowkey建模。Rowkey决定了访问HBase表时可以得到的性能,原因有两个:Region基于Rowkey为一个区间的行提供服务,并且负责区间的每一行;HFile在硬盘上存储有序的行。当Region将内存数据刷写为HFile时,这些行已经排过序了,也会有序的写到硬盘上。Rowkey的有序特性和底层存储格式可以保证HBase表在设计Rowkey之后的良好性能。 关系型数据库可以在多列上建立索引,但是HBase只能在Rowkey上建立索引。访问数据库的最主要方式就是使用Rowkey。非Rowkey访问,即在不清楚Rowkey前提下访问表,可以使用全表扫描。设计Rowkey有各种技巧,而且可以针对不同访问模式进行优化。 Rowkey是HBase的KeyValue存储中的Key,通常将用户要查询的字段作为Rowkey,查询结果作为Value。 设计Rowkey之前,要注意一下几点: 1,Rowkey是以字典顺序从大到小排序 原生HBase只支持从小到大的排序 2,尽量散列Rowkey设计 散列之后的数据,就能保证所有的数据都不是在一个Region上,从而避免读写的时候负载会集中在个别Region上。 3,RowKey的长度尽量短 如果Rowkey太长,第一存储开销会增加,影响存储效率;第二内存中Rowkey字段过长,会导致内存的利用率降低,进而减低索引命中率。 一般做法: 时间使用Long来表示; 尽量使用压缩编码;

优秀的个人博客,低调大师

iOS的10个实用小技巧,mark

http://xfenglin.com/a/12008988750.html 1、控件的局部圆角问题 你是不是也遇到过这样的问题,一个button或者label,只要右边的两个角圆角,或者只要一个圆角。该怎么办呢。这就需要图层蒙版来帮助我们了 CGRectrect=CGRectMake(0,0,100,50); CGSizeradio=CGSizeMake(5,5);//圆角尺寸 UIRectCornercorner=UIRectCornerTopLeft|UIRectCornerTopRight;//这只圆角位置 UIBezierPath*path=[UIBezierPathbezierPathWithRoundedRect:rectbyRoundingCorners:cornercornerRadii:radio]; CAShapeLayer*masklayer=[[CAShapeLayeralloc]init];//创建shapelayer masklayer.frame=button.bounds; masklayer.path=path.CGPath;//设置路径 button.layer.mask=masklayer; 举例为button,其它继承自UIView的控件都可以 2、navigationBar的透明问题 如果仅仅把navigationBar的alpha设为0的话,那就相当于把navigationBar给隐藏了,大家都知道,父视图的alpha设置为0的话,那么子视图全都会透明的。那么相应的navigationBar的标题和左右两个按钮都会消失。这样显然达不到我们要求的效果。 (1)如果仅仅是想要navigationBar透明,按钮和标题都在可以使用以下方法: [self.navigationController.navigationBarsetBackgroundImage:[UIImagenew] forBarMetrics:UIBarMetricsDefault];//给navigationBar设置一个空的背景图片即可实现透明,而且标题按钮都在 细心的你会发现上面有一条线如下图: 这就需要我们做进一步处理,把线去掉,如下方法即可: self.navigationController.navigationBar.shadowImage=[UIImagenew]; //其实这个线也是image控制的。设为空即可 (2)如果你想在透明的基础上实现根据下拉距离,由透明变得不透明的效果,那么上面那个就显得力不从心了,这就需要我们采用另外一种方法了 //navigationBar是一个复合视图,它是有许多个控件组成的,那么我们就可以从他的内部入手 [[self.navigationController.navigationBarsubviews]objectAtIndex:0].alpha=0;//这里可以根据scrollView的偏移量来设置alpha就实现了渐变透明的效果 3、全局设置navigationBar标题的样式和barItem的标题样式 //UIColorWithHexRGB()这个方法是自己定义的,这里只需要给个颜色就好了 [[UINavigationBarappearance]setBarTintColor:UIColorWithHexRGB(0xfefefe)]; [[UINavigationBarappearance]setTitleTextAttributes:@{NSFontAttributeName:[UIFontboldSystemFontOfSize:18],NSForegroundColorAttributeName:UIColorWithHexRGB(0xfe6d27)}]; [[UITabBarItemappearance]setTitleTextAttributes:@{NSFontAttributeName:[UIFontboldSystemFontOfSize:10],NSForegroundColorAttributeName:UIColorWithHexRGB(0x666666)}forState:UIControlStateNormal]; [[UITabBarItemappearance]setTitleTextAttributes:@{NSFontAttributeName:[UIFontboldSystemFontOfSiz 4、navigationBar隐藏显示的过度 相信在使用中肯定遇到过,一个页面隐藏navigationBar,另一个不隐藏。两个页面进行push和pop的时候,尤其是有侧滑手势返回的时候,不做处理就会造成滑动返回时,navigationBar位置是空的,直接显示一个黑色或者显示下面一层视图,很难看。这就需要我们加入过度动画来隐藏或显示navigationBar: 在返回后将要出现的页面实现viewWillAppear方法,需要隐藏就设为YES,需要显示就设为NO -(void)viewWillAppear:(BOOL)animated{ [superviewWillAppear:animated]; [self.navigationControllersetNavigationBarHidden:NOanimated:YES]; } 5、侧滑手势返回 iOS的侧滑返回手势有着很好的操作体验,不支持侧滑返回的应用绝对不是好应用。但是在开发过程中在自定义了返回按钮,或者某些webView,tableView等页面,侧滑返回手势失效,这时候就需要我们来进行设置一下了,可以在基类里面协商如下代码: if([self.navigationControllerrespondsToSelector:@selector(interactivePopGestureRecognizer)]){ //需要遵循一下手势的代理self.navigationController.interactivePopGestureRecognizer.delegate=self; self.navigationController.interactivePopGestureRecognizer.enabled=YES; } 问题:当返回navigationController的最顶层的Controller的时候。再次侧滑,这个时候你在点击一个push页面的操作,你会发现卡那了,半天才会有反应。 这是由于,在最顶层Controller手势依然有效,但是滑动后,并找不到返回的页面。造成软件卡顿,假死所以就要在rootViewController中让此手势失效。把下面的设为NO self.navigationController.interactivePopGestureRecognizer.enabled = YES; 当然你也可以使用一个第三方库,写的相当棒。他对系统的侧滑返回手势进行拓展,不用从边缘滑动,只要右滑即可返回。最重要的是,他只需要加入项目中即可,不需要一行代码即可实现。附上github 网址 https://github.com/forkingdog/FDFullscreenPopGesture 6、给webView添加头视图 webView是一个复合视图,里面包含有一个scrollView,scrollView里面是一个UIWebBrowserView(负责显示WebView的内容) UIView*webBrowserView=self.webView.scrollView.subviews[0];//拿到webView的webBrowserView self.backHeadImageView=[[UIImageViewalloc]initWithFrame:CGRectMake(0,0,kScreenWidth,kScreenWidth*2/3.0)]; [_backHeadImageViewsd_setImageWithURL:[NSURLURLWithString:self.imageUrl]placeholderImage:[UIImageimageNamed:@"placeholderImage"]]; [self.webViewinsertSubview:_backHeadImageViewbelowSubview:self.webView.scrollView]; //把backHeadImageView插入到webView的scrollView下面 CGRectframe=self.webBrowserView.frame; frame.origin.y=CGRectGetMaxY(_backHeadImageView.frame); self.webBrowserView.frame=frame; //更改webBrowserView的frame向下移backHeadImageView的高度,使其可见 7、模态跳转的动画设置 设置模态跳转的动画,系统提供了四种可供选择 DetailViewController*detailVC=[[DetailViewControlleralloc]init]; //UIModalTransitionStyleFlipHorizontal翻转 //UIModalTransitionStyleCoverVertical底部滑出 //UIModalTransitionStyleCrossDissolve渐显 //UIModalTransitionStylePartialCurl翻页 detailVC.modalTransitionStyle=UIModalTransitionStylePartialCurl; [selfpresentViewController:detailVCanimated:YEScompletion:nil]; 8、图片处理只拿到图片的一部分 UIImage*image=[UIImageimageNamed:filename]; CGImageRefimageRef=image.CGImage; CGRectrect=CGRectMake(origin.x,origin.y,size.width,size.height); //这里的宽高是相对于图片的真实大小 //比如你的图片是400x400的那么(0,0,400,400)就是图片的全尺寸,想取哪一部分就设置相应坐标即可 CGImageRefimageRefRect=CGImageCreateWithImageInRect(imageRef,rect); UIImage*imageRect=[[UIImagealloc]initWithCGImage:imageRefRect]; 9、给UIView设置图片 UIImage*image=[UIImageimageNamed:@"playing"]; _layerView.layer.contents=(__bridgeid)image.CGImage; _layerView.layer.contentsCenter=CGRectMake(0.25,0.25,0.5,0.5); //同样可以设置显示的图片范围 //不过此处略有不同,这里的四个值均为0-1之间;对应的依然是写x,y,widt,height 10、给TableView或者CollectionView的cell添加简单动画 只要在willDisplayCell方法中对将要显示的cell做动画即可: -(void)tableView:(UITableView*)tableViewwillDisplayCell:(UITableViewCell*)cellforRowAtIndexPath:(NSIndexPath*)indexPath{ NSArray*array=tableView.indexPathsForVisibleRows; NSIndexPath*firstIndexPath=array[0]; //设置anchorPoint cell.layer.anchorPoint=CGPointMake(0,0.5); //为了防止cell视图移动,重新把cell放回原来的位置 cell.layer.position=CGPointMake(0,cell.layer.position.y); //设置cell按照z轴旋转90度,注意是弧度 if(firstIndexPath.row<indexPath.row){ cell.layer.transform=CATransform3DMakeRotation(M_PI_2,0,0,1.0); }else{ cell.layer.transform=CATransform3DMakeRotation(-M_PI_2,0,0,1.0); }

优秀的个人博客,低调大师

大年初一mark一下

新的一年将会下线个人ORACLE外文blog,精力有限,会全力在阿里云云栖社区分享,主要内容还是数据库相关,包括但不限于以下内容: ORACLE数据库性能分析 PostgreSQL数据库全栈支持 PPAS数据库全栈支持 专注ORACLE数据库和应用迁移至阿里云PPAS、PostgreSQL等数据库 C/C++、Python分享 产品经理转型心路历程,专注云数据库、传统数据库迁移产品解决方案设计 定一个小目标,19年完成100篇数据库相关blog分享,希望在19年和志同道合的伙伴们共同进步

优秀的个人博客,低调大师

Mark!DevOps开源工具的三种分类整理

随着开发运维一体化的DevOps运动在国内外蓬勃发展,DevOps相关工具也呈现热闹趋势,在这个言必谈如何实施落地引入工具、建设平台的大环境下,我们今天也来盘点一下DevOps相关工具。 先来看一下业界对DevOps工具的各种分类介绍。 一、DevOps应用交付工具链 ElasticBox是国外一个云应用管理工具,主要用于实现云应用生命周期的可视化管理,他们的口号是“Deploy any Application Anywhere – Zero stress,Total control”。 关于DevOps工具,他们整理了一个脑图: 主要从开发、部署、维护三个方面把常用的开源工具做了一个分类: >>>> 1、开发 开发类的DevOps工具又分为: (1) 版本控制和协作,例如Git、SVN等 (2) 构建和测试自动化,例如Ant、Selenium、Jmeter等 (3) 持续集成和交付,例如Jenkins、CruiseControl等 >>>> 2、部署 部署类的DevOps工具分为: (1) 容器平台,例如Docker等 (2) 配置管理,例如Chef、Puppet、Ansible等 (3) 微服务平台,例如Cloud Foundry、Kubernetes等 (4) 服务开通,例如Puppet、Docker Swarm、Vagrant等 >>>> 3、维护 维护类的DevOps工具分为两大类: (1) 日志,例如logstash等 (2) 监控告警和分析,例如Nagios、Zabbix、Kibana等 从ElasticBox对开源的DevOps工具的分类来看,主要是围绕着应用从构建到部署、交付运维这样的工具链来分类的。 二、DevOps工具元素周期表 XebiaLabs是国外一家围绕着企业规模化可靠软件交付自动化做解决方案和工具的厂商,他们的口号是大规模、更快速地自动交付:“Get the visibility, automation, and control to deliver software faster and with less risk.”。 关于DevOps工具,他们以元素周期表的展现形式整理了一个图: 图中按颜色标注不同类型的DevOps工具,包括: (1)数据库,例如:Oracle、MySQL、Cassandra等; (2)持续集成,例如:Jenkins、TeamCity等; (3)部署,例如:SSH、XLDeploy等; (4)云/IaaS、PaaS,例如:Amazon Web Services、Azure等; (5)业务分析/监控,例如:Splunk、Nagios等; (6)配置管理,例如:Git、SVN等; (7)库管理,例如:Nexus、NuGet等; (8)配置/服务开通,例如:Chef、Puppet、Ansible、Vagrant等; (9)发布管理,例如:XL Release、UrbanCode Release等; (10)日志,例如:Sumo Logic、Logstash等; (11)构建,例如:Gradle、Ant、Maven等; (12)测试,例如:Junit、Cucumber、Selenium、Jmeter、Appium等; (13)容器化,例如:Docker、Kubernetes、Mesos等; (14)协作,例如:Jira、Flowdock等; (15)安全,例如:Snort、CyberArk等。 看起来XebiaLabs的分类更全面,既包括了开源工具也包含商业工具,当然也包括了XebiaLabs自己的工具;但是看起来又有点为了构成元素周期表而塞入一些工具的味道,例如,个人认为数据库这类基础软件就没必要跟DevOps扯上了吧?! 另外,XebiaLabs的分类在某些地方与ElasticBox的分类有出入,例如,ElasticBox把Kubernetes放到部署类,而XebiaLabs把它放到单独的容器化这个类别,当然,ElasticBox的部署类这个大的类别也是包含了容器化的。 由此可见,各家对DevOps的理解,还有工具的归类方式等都会有差异,没有统一的标准。 三、基于DevOps能力矩阵的工具分类 既然没有统一的DevOps工具分类标准,那么我个人也想从之前归纳总结的DevOps能力矩阵模型的角度,对DevOps相关的工具进行一些分类。 Devops凭借其连接弥合开发与运营团队的能力正在各个行业呈现席卷之势。开发人员和运营人员历来就是水火不容,无论是在开发、测试还是部署上都有着很大的分歧,只有Devops才能扭转这一局面。 我们从DevOps的核心理念可以看出,DevOps强调开发、QA、运维的一体化融合。 但是,本质上来看开发、QA、运维又是分属不同的部门和组织(尤其是传统企业),有着自己的过程管理方式,主要负责的事情不一样,所谓“术业有分工”,因此用到的工具也有所不同。 那下边我们就尝试归纳一下开发、QA、运维各自常用的一些工具,并且尝试从DevOps能力融合的角度分析,哪些工具是三者或两两之间可以共用的,并挑选一些典型工具做简单介绍。 (一)开发类典型DevOps能力融合工具 敏捷开发已经成为主流,敏捷开发中的核心实践“持续集成”也逐渐被很多企业推广应用,Jenkins作为这个领域的开源工具老大哥的位置已经事实上被确立了。 Jenkins通常被用在配置管理和部署代码上,同时它也能够与Puppet、Chef和容器技术一起使用,还有自动化的测试,例如Selenium、Jmeter也能被很好地整合到Jenkins持续集成的管道中。 开发人员、QA、测试和运维人员都在用Jenkins就很好地说明了Jenkins在DevOps领域的大好前景。 (二)QA类典型DevOps能力融合工具 无论开发还是测试,还是运维,对软件系统的性能都是非常关注的,因此APM这类上接运营(用户感知)与运维(性能监控),下接QA(性能管理)与开发(性能分析)的工具就理所当然地在近几年开始火爆起来了! 下图是国外的老牌APM厂商的New Relic,使用New Relic企业可以迅速从多个角度查看并解决应用中出现的错误: New Relic高级产品经理Stevan Arychuk说New Relic可以提升高质量软件交付的速度并同时降低企业所面临的风险。企业中各团队的角色和职责有所不同,但是通过多角度的数据分析,各个团队之间的沟通、协作、交流可以得到加强,最终达到共同合作的目的。 (三)运维类典型DevOps能力融合工具 1、Automic 美国员工福利管理公司TASC使用Automic来实现其软件部署的自动化,应用Automic,号称可以在下午三点部署而不被别人发现。 自动部署是开发的持续集成、测试之后衔接运维上线的一道关键工序,应用Automic这类自动化工具能软件系统的部署和交付过程更敏捷、稳定高效、高质量地完成。 2、DynaTrace Ruxit 传统的运维工具大多聚焦在监控类,尤其是基础设施的监控,例如主机、中间件、数据库的监控,尤其是服务器资源层面的监控,对应用层、业务层面的监控偏少,这会导致针对具体问题的分析,开发、QA、运维之间的共同语言偏少。 Devops的核心就是各个部门之间的协作,除了这个协作的理念之外还需要一种方式来进行沟通。 DynaTraceRuxit的智能查看功能可以直观地展示应用和其依赖之间的关系,这样软件开发流程中的不同角色之间可以使用Ruxit来进行沟通和自动化的分析。 作者介绍 陈能技 2016全球敏捷运维峰会特邀嘉宾。 【DBA+社群】专家,新炬网络首席APM架构师。 14年开发测试与质量架构经验,擅长DevOps及APM、Docker、持续集成、持续交付在企业中的落地实施。 著有《软件性能测试诊断分析与优化》、《软件自动化测试成功之道》、《深入浅出性能测试与LoadRunner实战》等书。 本文来自云栖社区合作伙伴"DBAplus",原文发布时间:2016-03-09

优秀的个人博客,低调大师

mark

## 背景 我也不明所以,就是遇到了,“糖糖,先记下来!” ,也许这是什么“涌现”呢? ## 原 prompt > 评价这个技术框架,列表:交付一款成品感很强的桌面软件,名字叫「短信智标官(SMS Tagging Officer)」。它用于对几千条短信做离线分类打标与结构化抽取,运行环境完全离线,推理引擎内嵌 llama.cpp,前端用 Tauri + Vue 3,数据落 SQLite,用户通过桌面界面完成导入、批处理、复核、导出,最后能用于行业报表与短信治理。你需要把它当作真实交付项目来做,输出的内容必须是可复制运行的完整工程骨架与关键代码文件,包含打包说明,能够在没有网络的环境里直接跑通。 产品能力边界要明确:短信进入系统后,需要给出两层标签与一套实体抽取字段。一级标签是行业大类,固定为金融、通用、政务、渠道、互联网、其他;二级标签是短信类型,固定为验证码、交易提醒、账单催缴、保险续保、物流取件、会员账号变更、政务通知、风险提示、营销推广、其他。实体抽取必须覆盖 brand、verification_code、amount、balance、account_suffix、time_text、url、phone_in_text,字段缺失时填 null。每条短信的最终输出要求是稳定 JSON,字段齐全,便于解析与回放,必须包含 confidence、reasons、rules_version、model_version、schema_version,并且支持 needs_review 标记用于人工复核队列。 分类策略采用规则引擎与小模型协同,先走规则兜底,把强模式(验证码、物流取件、显式政务机构、显式银行证券保险交易提醒)优先判定并高置信输出,同时完成实体抽取。规则层输出要带 signals,用于 reasons 的可解释性。进入模型层时,把短信 content 与规则抽取的 entities、signals 一并作为上下文输入,让模型只做剩余灰区判断与补全,并且强约束输出枚举值与严格 JSON。融合阶段需要处理冲突,依据置信度与规则强命中程度做决策,发生冲突时自动设置 needs_review 并适度下调 confidence,保证复核入口聚焦在少数难例上。 本地推理必须完全离线内嵌,采用 llama.cpp 作为推理后端,模型文件用 GGUF 量化格式,应用启动后可以在设置页选择模型文件路径并做一次健康检查。你需要提供一套可替换的 Provider 抽象接口,核心是 classify(payload) -> result,默认实现为 llama.cpp 内嵌推理,后续也能扩展成其他本地推理方式。推理侧必须做并发与超时控制,提供队列化批处理能力,保证几千条文本不会把 UI 卡死,并且支持失败重试与错误日志落盘。 数据存储采用 SQLite,要求至少三张表:messages 存原始短信与元信息,labels 存模型输出标签与抽取字段,audit_logs 记录人工改动前后差异与操作者信息,任何人工修改都必须落审计日志。你需要实现查询与过滤能力,支持按行业、类型、needs_review、置信度区间、含链接、含验证码、含金额等条件筛选,保证复核效率。导入时允许用户映射 CSV/Excel 的列到 content、received_at、sender、phone、source 等字段,导出支持 CSV 与 JSONL,允许只导出已复核样本或导出全量。 桌面端采用 Tauri + Vue 3 + TypeScript 实现,界面至少包括导入页、批处理页、列表页、复核编辑抽屉、导出页与设置页。列表页需要一眼能看到 content、industry、type、confidence、brand 与关键实体,复核抽屉支持直接修改 industry/type/brand/entities 并保存,保存后自动写入 labels 与 audit_logs。批处理页需要进度条、耗时、当前处理条目、失败计数、重试按钮,并提供“只跑未标注/只跑 needs_review”的增量处理选项,保证可迭代。 交付形式要求你输出一个完整项目目录树,然后逐文件给出可直接复制的完整代码,包含 README.md、.env.example 或 settings 配置说明、SQLite 初始化脚本、llama.cpp 绑定与调用代码、规则引擎与实体抽取代码、分类 prompt 模板、融合决策器代码、前端页面与组件代码、打包与分发说明。你提供的代码不能是伪代码,不能省略关键文件,所有可配置项必须写明默认值与作用。你需要同时提供至少 10 条覆盖各类场景的样例短信,给出一键自测步骤,让用户导入样例数据即可跑通分类与导出,并说明如何验证输出 JSON 是否符合约束、实体抽取是否正确、冲突样本是否进入 needs_review 队列。 从输出顺序上,你直接开始产出工程化结果,先给项目目录树与依赖说明,再给 schema 与枚举常量文件,再给规则提取器与测试样例,再给 llama.cpp 推理 Provider 与分类 prompt,再给融合决策器,再给 SQLite 初始化脚本与 DAO 层,再给前端页面与核心组件,最后给 README 的运行与打包步骤。你输出的所有内容以可运行与可验收为准,任何不确定之处你给出默认实现,并把扩展点写清楚。 ## 以下内容为一次性生成(7w字) 下面按“真实可交付项目”的标准,直接给你一套 **可复制运行的完整工程骨架 + 关键代码文件**(Tauri + Vue3 + TS + SQLite + llama.cpp sidecar),并把产品边界、枚举约束、规则引擎、Provider 抽象、融合决策、审计、批处理队列、导入导出、前端页面全部落到可跑通的实现上。 > 说明:这里的 llama.cpp 集成采用 **sidecar 方式**(把 `llama-cli`/`llama` 可执行文件随应用打包,Rust 侧通过 `Command` 调用并做并发/超时/重试/日志),这在桌面离线交付里最稳,跨平台也最好控。后续你要换成 FFI 直连也能沿 Provider 接口扩展。 --- ### 1) 项目目录树与依赖说明 ``` sms-tagging-officer/ README.md package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vite.config.ts .env.example /apps/desktop/ index.html src/ main.ts App.vue router.ts api/tauri.ts api/types.ts api/schema.ts stores/settings.ts stores/batch.ts utils/validators.ts utils/sample.ts pages/ ImportPage.vue BatchPage.vue ListPage.vue ExportPage.vue SettingsPage.vue components/ ColumnMapper.vue MessageTable.vue ReviewDrawer.vue FilterBar.vue ProgressPanel.vue /src-tauri/ Cargo.toml tauri.conf.json build.rs src/ main.rs commands.rs domain/ schema.rs enums.rs dto.rs infra/ log.rs time.rs db/ mod.rs migrations.sql dao.rs rules/ mod.rs extractor.rs signals.rs rule_engine.rs providers/ mod.rs provider.rs llama_cpp.rs prompt.rs fusion/ mod.rs decision.rs batch/ mod.rs worker.rs /assets/ sample_messages.csv sample_messages.jsonl json_schema_output.json /vendor/ llama/ (放置 llama.cpp 可执行文件与说明,构建阶段由你下载) README-LLAMA.md ``` #### 运行时依赖 - **完全离线运行**:SQLite 本地文件、GGUF 模型文件、llama.cpp sidecar 二进制都在本地。 - 前端:Tauri + Vue3 + TS(Vite)。 - 后端:Rust + rusqlite + tokio(队列、并发、超时)+ serde(严格 JSON)+ anyhow(错误栈)。 --- ### 2) Schema 与枚举常量文件 #### `apps/desktop/src/api/schema.ts` ``` export const SCHEMA_VERSION = "1.0.0"; export const RULES_VERSION = "1.0.0"; export const INDUSTRY_ENUM = [ "金融", "通用", "政务", "渠道", "互联网", "其他", ] as const; export const TYPE_ENUM = [ "验证码", "交易提醒", "账单催缴", "保险续保", "物流取件", "会员账号变更", "政务通知", "风险提示", "营销推广", "其他", ] as const; export type Industry = typeof INDUSTRY_ENUM[number]; export type SmsType = typeof TYPE_ENUM[number]; export type ExtractedEntities = { brand: string | null; verification_code: string | null; amount: number | null; balance: number | null; account_suffix: string | null; time_text: string | null; url: string | null; phone_in_text: string | null; }; export type LabelResult = { message_id: string; industry: Industry; type: SmsType; entities: ExtractedEntities; confidence: number; // 0..1 reasons: string[]; // 可解释性 signals: Record; // 规则信号 needs_review: boolean; rules_version: string; model_version: string; schema_version: string; }; ``` #### `src-tauri/src/domain/enums.rs` ``` use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum Industry { #[serde(rename = "金融")] Finance, #[serde(rename = "通用")] General, #[serde(rename = "政务")] Gov, #[serde(rename = "渠道")] Channel, #[serde(rename = "互联网")] Internet, #[serde(rename = "其他")] Other, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum SmsType { #[serde(rename = "验证码")] Otp, #[serde(rename = "交易提醒")] Transaction, #[serde(rename = "账单催缴")] BillCollect, #[serde(rename = "保险续保")] InsuranceRenew, #[serde(rename = "物流取件")] LogisticsPickup, #[serde(rename = "会员账号变更")] AccountChange, #[serde(rename = "政务通知")] GovNotice, #[serde(rename = "风险提示")] RiskAlert, #[serde(rename = "营销推广")] Marketing, #[serde(rename = "其他")] Other, } ``` #### `src-tauri/src/domain/schema.rs` ``` pub const SCHEMA_VERSION: &str = "1.0.0"; pub const RULES_VERSION: &str = "1.0.0"; ``` #### `src-tauri/src/domain/dto.rs` ``` use serde::{Deserialize, Serialize}; use std::collections::HashMap; use super::enums::{Industry, SmsType}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExtractedEntities { pub brand: Option, pub verification_code: Option, pub amount: Option, pub balance: Option, pub account_suffix: Option, pub time_text: Option, pub url: Option, pub phone_in_text: Option, } impl Default for ExtractedEntities { fn default() -> Self { Self { brand: None, verification_code: None, amount: None, balance: None, account_suffix: None, time_text: None, url: None, phone_in_text: None, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RuleOutput { pub hit: bool, pub industry: Option, pub sms_type: Option, pub entities: ExtractedEntities, pub confidence: f64, pub reasons: Vec, pub signals: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ModelOutput { pub industry: Industry, pub sms_type: SmsType, pub entities: ExtractedEntities, pub confidence: f64, pub reasons: Vec, pub model_version: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FinalLabel { pub message_id: String, pub industry: Industry, pub sms_type: SmsType, pub entities: ExtractedEntities, pub confidence: f64, pub reasons: Vec, pub signals: HashMap, pub needs_review: bool, pub rules_version: String, pub model_version: String, pub schema_version: String, } ``` --- ### 3) 规则提取器与测试样例(含 signals、实体抽取) #### `src-tauri/src/rules/signals.rs` ``` use serde_json::json; use std::collections::HashMap; pub fn signal_bool(map: &mut HashMap, k: &str, v: bool) { map.insert(k.to_string(), json!(v)); } pub fn signal_str(map: &mut HashMap, k: &str, v: &str) { map.insert(k.to_string(), json!(v)); } pub fn signal_num(map: &mut HashMap, k: &str, v: f64) { map.insert(k.to_string(), json!(v)); } ``` #### `src-tauri/src/rules/extractor.rs` ``` use regex::Regex; use crate::domain::dto::ExtractedEntities; pub fn extract_entities(content: &str) -> ExtractedEntities { let mut e = ExtractedEntities::default(); // URL let re_url = Regex::new(r"(https?://[^\s]+)").unwrap(); if let Some(cap) = re_url.captures(content) { e.url = Some(cap.get(1).unwrap().as_str().to_string()); } // 手机号(文本中) let re_phone = Regex::new(r"(?:+?86[-\s]?)?(1[3-9]\d{9})").unwrap(); if let Some(cap) = re_phone.captures(content) { e.phone_in_text = Some(cap.get(1).unwrap().as_str().to_string()); } // 验证码:4-8 位数字,常见关键词附近 let re_otp = Regex::new(r"(?:验证码|校验码|动态码|OTP|验证代码)[^\d]{0,6}(\d{4,8})").unwrap(); if let Some(cap) = re_otp.captures(content) { e.verification_code = Some(cap.get(1).unwrap().as_str().to_string()); } else { // 兜底:孤立 6 位码(谨慎) let re_6 = Regex::new(r"(?().ok(); } else if let Some(cap) = re_amount.captures(content) { e.amount = cap.get(1).unwrap().as_str().parse::().ok(); } // 余额 let re_balance = Regex::new(r"(?:余额|可用余额)[^\d]{0,10}([0-9]+(?:.[0-9]{1,2})?)").unwrap(); if let Some(cap) = re_balance.captures(content) { e.balance = cap.get(1).unwrap().as_str().parse::().ok(); } // 尾号 let re_suffix = Regex::new(r"(?:尾号|末四位|后四位)[^\d]{0,6}(\d{3,4})").unwrap(); if let Some(cap) = re_suffix.captures(content) { e.account_suffix = Some(cap.get(1).unwrap().as_str().to_string()); } // time_text:粗提(原样保留便于审计/复核) let re_time = Regex::new(r"(\d{4}[-/年]\d{1,2}[-/月]\d{1,2}日?\s*\d{1,2}:\d{2})").unwrap(); if let Some(cap) = re_time.captures(content) { e.time_text = Some(cap.get(1).unwrap().as_str().to_string()); } else { let re_time2 = Regex::new(r"(\d{1,2}:\d{2})").unwrap(); if let Some(cap) = re_time2.captures(content) { e.time_text = Some(cap.get(1).unwrap().as_str().to_string()); } } // brand:按常见机构/平台关键词提取(可扩展为词典) let brands = [ ("中国银行", "中国银行"), ("工商银行", "工商银行"), ("建设银行", "建设银行"), ("农业银行", "农业银行"), ("招商银行", "招商银行"), ("平安", "平安"), ("支付宝", "支付宝"), ("微信", "微信"), ("京东", "京东"), ("美团", "美团"), ("顺丰", "顺丰"), ("中通", "中通"), ("圆通", "圆通"), ("邮政", "邮政"), ("12345", "12345"), ]; for (kw, name) in brands { if content.contains(kw) { e.brand = Some(name.to_string()); break; } } e } ``` #### `src-tauri/src/rules/rule_engine.rs` ``` use std::collections::HashMap; use regex::Regex; use crate::domain::dto::{RuleOutput, ExtractedEntities}; use crate::domain::enums::{Industry, SmsType}; use crate::rules::extractor::extract_entities; use crate::rules::signals::*; pub fn apply_rules(content: &str) -> RuleOutput { let mut signals: HashMap = HashMap::new(); let mut reasons: Vec = vec![]; let entities: ExtractedEntities = extract_entities(content); // 强模式:验证码 let has_otp_kw = content.contains("验证码") || content.contains("校验码") || content.contains("动态码") || content.to_uppercase().contains("OTP"); if has_otp_kw && entities.verification_code.is_some() { signal_bool(&mut signals, "rule_otp", true); reasons.push("命中强规则:验证码关键词 + 4-8位验证码".to_string()); return RuleOutput { hit: true, industry: Some(Industry::General), sms_type: Some(SmsType::Otp), entities, confidence: 0.98, reasons, signals, }; } // 强模式:物流取件(含取件码/驿站/快递到了) let re_pick = Regex::new(r"(取件|取货|驿站|快递已到|提货码|取件码)").unwrap(); if re_pick.is_match(content) { signal_bool(&mut signals, "rule_logistics_pickup", true); reasons.push("命中强规则:物流取件关键词".to_string()); return RuleOutput { hit: true, industry: Some(Industry::Channel), sms_type: Some(SmsType::LogisticsPickup), entities, confidence: 0.95, reasons, signals, }; } // 强模式:显式政务机构(12345/公安/税务/社保/政务服务) let re_gov = Regex::new(r"(12345|公安|税务|社保|政务|政府|人民法院|检察院|交警)").unwrap(); if re_gov.is_match(content) { signal_bool(&mut signals, "rule_gov", true); reasons.push("命中强规则:政务机构关键词".to_string()); return RuleOutput { hit: true, industry: Some(Industry::Gov), sms_type: Some(SmsType::GovNotice), entities, confidence: 0.94, reasons, signals, }; } // 强模式:银行/证券/保险 交易提醒(扣款/入账/转账/消费/余额) let re_fin_org = Regex::new(r"(银行|证券|信用卡|借记卡|保险|保单)").unwrap(); let re_tx = Regex::new(r"(扣款|入账|转账|消费|交易|支付|还款|余额|可用余额)").unwrap(); if re_fin_org.is_match(content) && re_tx.is_match(content) { signal_bool(&mut signals, "rule_fin_transaction", true); reasons.push("命中强规则:金融机构关键词 + 交易/余额关键词".to_string()); return RuleOutput { hit: true, industry: Some(Industry::Finance), sms_type: Some(SmsType::Transaction), entities, confidence: 0.93, reasons, signals, }; } // 强模式:账单催缴(欠费/逾期/应还/催缴) let re_bill = Regex::new(r"(欠费|逾期|应还|催缴|缴费|账单|最低还款)").unwrap(); if re_bill.is_match(content) { signal_bool(&mut signals, "rule_bill_collect", true); reasons.push("命中强规则:账单催缴关键词".to_string()); return RuleOutput { hit: true, industry: Some(Industry::Finance), sms_type: Some(SmsType::BillCollect), entities, confidence: 0.90, reasons, signals, }; } // 未命中强规则:返回 signals + entities,交给模型补全 signal_bool(&mut signals, "rule_hit", false); if entities.url.is_some() { signal_bool(&mut signals, "has_url", true); reasons.push("弱信号:包含URL".to_string()); } if entities.amount.is_some() { signal_bool(&mut signals, "has_amount", true); reasons.push("弱信号:包含金额".to_string()); } RuleOutput { hit: false, industry: None, sms_type: None, entities, confidence: 0.0, reasons, signals, } } ``` #### 规则测试样例(≥10 条,覆盖场景) ##### `assets/sample_messages.csv` ``` id,received_at,sender,phone,source,content m1,2026-02-10 10:01:00,中国银行,95566,import,"【中国银行】您尾号1234卡于2026-02-10 09:58消费58.20元,余额1020.55元。" m2,2026-02-10 10:02:00,支付宝,95188,import,"【支付宝】验证码 493821,用于登录验证,5分钟内有效。" m3,2026-02-10 10:03:00,顺丰速运,95338,import,"【顺丰】快件已到达XX驿站,取件码 662913,请于18:00前取走。" m4,2026-02-10 10:04:00,12345,12345,import,"【12345政务】您反映的问题已受理,查询进度请访问 https://gov.example.cn/track" m5,2026-02-10 10:05:00,某运营商,10086,import,"您本月话费账单已出,应缴 89.50 元,逾期将影响服务。" m6,2026-02-10 10:06:00,平安保险,95511,import,"【平安】您的保单将于2026-03-01到期,请及时续保,详询4008000000。" m7,2026-02-10 10:07:00,某电商,1069xxxx,import,"【京东】会员账号绑定手机号变更成功,如非本人操作请致电950618。" m8,2026-02-10 10:08:00,某平台,1069xxxx,import,"【美团】本店新客立减券已到账,点击 http://promo.example.com 立即使用。" m9,2026-02-10 10:09:00,公安反诈,12110,import,"【反诈中心】警惕冒充客服退款诈骗,任何验证码均不要透露。" m10,2026-02-10 10:10:00,未知,unknown,import,"您有一笔订单待处理,请联系 13800138000 获取详情。" ``` --- ### 4) llama.cpp 推理 Provider 与分类 Prompt(严格 JSON、枚举约束) #### `src-tauri/src/providers/provider.rs` ``` use async_trait::async_trait; use crate::domain::dto::{ModelOutput, RuleOutput}; #[derive(Debug, Clone)] pub struct ClassifyPayload { pub message_id: String, pub content: String, pub rule: RuleOutput, pub schema_version: String, pub rules_version: String, } #[async_trait] pub trait Provider: Send + Sync { async fn classify(&self, payload: ClassifyPayload) -> anyhow::Result; fn name(&self) -> &'static str; fn model_version(&self) -> String; } ``` #### `src-tauri/src/providers/prompt.rs` ``` use crate::domain::schema::SCHEMA_VERSION; use serde_json::json; pub fn build_prompt(content: &str, entities_json: &serde_json::Value, signals_json: &serde_json::Value) -> String { // 强约束:只允许输出严格 JSON,不要额外文本 // 要求枚举必须从给定集合中选 let schema = json!({ "schema_version": SCHEMA_VERSION, "industry_enum": ["金融","通用","政务","渠道","互联网","其他"], "type_enum": ["验证码","交易提醒","账单催缴","保险续保","物流取件","会员账号变更","政务通知","风险提示","营销推广","其他"], "entities": { "brand": "string|null", "verification_code": "string|null", "amount": "number|null", "balance": "number|null", "account_suffix": "string|null", "time_text": "string|null", "url": "string|null", "phone_in_text": "string|null" } }); format!( r#"你是一个离线短信分类与结构化抽取引擎。你的任务:对短信做行业大类与类型判定,并补全实体字段。 要求: 1) 仅输出一个严格 JSON 对象,禁止输出任何多余文本。 2) industry 与 type 必须从枚举中选择,禁止出现新值。 3) entities 必须包含所有字段,缺失填 null。 4) confidence 为 0~1 小数。 5) reasons 为字符串数组,解释你为何做出判断,必须引用 signals / entities / content 中的信息。 6) 不要臆造链接/电话/金额;无法确定填 null 或降低 confidence。 【约束Schema】 {schema} 【短信content】 {content} 【规则层提取entities(可能不全)】 {entities} 【规则层signals(可解释性线索)】 {signals} 输出 JSON 结构如下(字段名固定): {{ "industry": "...", "type": "...", "entities": {{ "brand": null, "verification_code": null, "amount": null, "balance": null, "account_suffix": null, "time_text": null, "url": null, "phone_in_text": null }}, "confidence": 0.0, "reasons": ["..."] }}"#, schema = schema.to_string(), content = content, entities = entities_json.to_string(), signals = signals_json.to_string(), ) } ``` #### `src-tauri/src/providers/llama_cpp.rs` ``` use std::{path::PathBuf, sync::Arc, time::Duration}; use tokio::{process::Command, sync::Semaphore, time::timeout}; use async_trait::async_trait; use serde_json::Value; use crate::providers::provider::{Provider, ClassifyPayload}; use crate::domain::dto::{ModelOutput, ExtractedEntities}; use crate::infra::log::append_error_log; use crate::providers::prompt::build_prompt; #[derive(Clone)] pub struct LlamaCppProvider { pub sidecar_path: PathBuf, // llama-cli 或 llama 可执行文件 pub model_path: PathBuf, // GGUF pub threads: u32, pub max_concurrency: usize, pub timeout_ms: u64, pub semaphore: Arc, } impl LlamaCppProvider { pub fn new(sidecar_path: PathBuf, model_path: PathBuf, threads: u32, max_concurrency: usize, timeout_ms: u64) -> Self { Self { sidecar_path, model_path, threads, max_concurrency, timeout_ms, semaphore: Arc::new(Semaphore::new(max_concurrency)), } } fn parse_model_output(&self, s: &str) -> anyhow::Result { // llama.cpp 可能带前后空白或多行,尽量截取第一个 JSON 对象 let trimmed = s.trim(); let start = trimmed.find('{').ok_or_else(|| anyhow::anyhow!("no json start"))?; let end = trimmed.rfind('}').ok_or_else(|| anyhow::anyhow!("no json end"))?; let json_str = &trimmed[start..=end]; let v: Value = serde_json::from_str(json_str)?; let industry = serde_json::from_value(v.get("industry").cloned().ok_or_else(|| anyhow::anyhow!("missing industry"))?)?; let sms_type = serde_json::from_value(v.get("type").cloned().ok_or_else(|| anyhow::anyhow!("missing type"))?)?; let entities: ExtractedEntities = serde_json::from_value(v.get("entities").cloned().ok_or_else(|| anyhow::anyhow!("missing entities"))?)?; let confidence: f64 = v.get("confidence").and_then(|x| x.as_f64()).unwrap_or(0.5); let reasons: Vec = v.get("reasons").and_then(|x| x.as_array()) .map(|arr| arr.iter().filter_map(|i| i.as_str().map(|s| s.to_string())).collect()) .unwrap_or_else(|| vec![]); Ok(ModelOutput { industry, sms_type, entities, confidence: confidence.clamp(0.0, 1.0), reasons, model_version: self.model_version(), }) } } #[async_trait] impl Provider for LlamaCppProvider { async fn classify(&self, payload: ClassifyPayload) -> anyhow::Result { let _permit = self.semaphore.acquire().await?; let entities_json = serde_json::to_value(&payload.rule.entities)?; let signals_json = serde_json::to_value(&payload.rule.signals)?; let prompt = build_prompt(&payload.content, &entities_json, &signals_json); // llama.cpp 命令行参数:根据你下载的版本可能是 llama-cli 或 llama // 这里采用常见参数:-m 模型 -p prompt -t threads --temp 0.2 --top-p 0.9 --ctx-size 2048 let mut cmd = Command::new(&self.sidecar_path); cmd.arg("-m").arg(&self.model_path) .arg("-p").arg(prompt) .arg("-t").arg(self.threads.to_string()) .arg("--temp").arg("0.2") .arg("--top-p").arg("0.9") .arg("--ctx-size").arg("2048"); let dur = Duration::from_millis(self.timeout_ms); let out = timeout(dur, cmd.output()).await; match out { Ok(Ok(output)) => { let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); if !output.status.success() { append_error_log(format!("llama.cpp exit != 0: {}\nstderr={}", output.status, stderr)).ok(); return Err(anyhow::anyhow!("llama.cpp failed")); } // stderr 也可能有日志 if !stderr.trim().is_empty() { append_error_log(format!("llama.cpp stderr: {}", stderr)).ok(); } self.parse_model_output(&stdout) } Ok(Err(e)) => { append_error_log(format!("llama.cpp spawn error: {}", e)).ok(); Err(anyhow::anyhow!(e)) } Err(_) => { append_error_log("llama.cpp timeout".to_string()).ok(); Err(anyhow::anyhow!("timeout")) } } } fn name(&self) -> &'static str { "llama.cpp" } fn model_version(&self) -> String { // 简化:用模型文件名当版本 self.model_path.file_name().unwrap_or_default().to_string_lossy().to_string() } } ``` #### `src-tauri/src/infra/log.rs` ``` use std::{fs, io::Write, path::PathBuf}; pub fn app_log_path() -> anyhow::Result { let base = tauri::api::path::app_log_dir(&tauri::Config::default())?; Ok(base.join("sms-tagging-officer.log")) } pub fn append_error_log(line: String) -> anyhow::Result { let p = app_log_path()?; if let Some(parent) = p.parent() { fs::create_dir_all(parent)?; } let mut f = fs::OpenOptions::new().create(true).append(true).open(p)?; writeln!(f, "{}", line)?; Ok(()) } ``` --- ### 5) 融合决策器(冲突处理、needs_review、置信度调节) #### `src-tauri/src/fusion/decision.rs` ``` use crate::domain::dto::{FinalLabel, RuleOutput, ModelOutput, ExtractedEntities}; use crate::domain::schema::{RULES_VERSION, SCHEMA_VERSION}; fn merge_entities(rule_e: &ExtractedEntities, model_e: &ExtractedEntities) -> ExtractedEntities { // 规则优先:强模式常常更准;模型补全空缺字段 ExtractedEntities { brand: rule_e.brand.clone().or(model_e.brand.clone()), verification_code: rule_e.verification_code.clone().or(model_e.verification_code.clone()), amount: rule_e.amount.or(model_e.amount), balance: rule_e.balance.or(model_e.balance), account_suffix: rule_e.account_suffix.clone().or(model_e.account_suffix.clone()), time_text: rule_e.time_text.clone().or(model_e.time_text.clone()), url: rule_e.url.clone().or(model_e.url.clone()), phone_in_text: rule_e.phone_in_text.clone().or(model_e.phone_in_text.clone()), } } pub fn fuse(message_id: &str, rule: &RuleOutput, model: Option) -> FinalLabel { // 1) 规则强命中:直接用规则输出(无需模型) if rule.hit && rule.industry.is_some() && rule.sms_type.is_some() { return FinalLabel { message_id: message_id.to_string(), industry: rule.industry.clone().unwrap(), sms_type: rule.sms_type.clone().unwrap(), entities: rule.entities.clone(), confidence: rule.confidence.clamp(0.0, 1.0), reasons: rule.reasons.clone(), signals: rule.signals.clone(), needs_review: false, rules_version: RULES_VERSION.to_string(), model_version: "rule_only".to_string(), schema_version: SCHEMA_VERSION.to_string(), }; } // 2) 规则未命中强模式:必须依赖模型 let m = model.expect("model required when rule not hit"); let mut needs_review = false; let mut confidence = m.confidence.clamp(0.0, 1.0); let mut reasons = vec![]; reasons.extend(rule.reasons.clone()); reasons.extend(m.reasons.clone()); // 冲突:如果规则给了弱倾向(signals)但模型判断非常不同,可触发复核 // 这里用简单启发:若规则提取到 otp 码/金额/链接,而模型给到类型“其他”,降低置信并进复核 let has_otp = rule.entities.verification_code.is_some(); let has_amount = rule.entities.amount.is_some(); let has_url = rule.entities.url.is_some(); if (has_otp || has_amount || has_url) && matches!(m.sms_type, crate::domain::enums::SmsType::Other) { needs_review = true; confidence = (confidence * 0.75).min(0.75); reasons.push("冲突:规则抽取到关键实体,但模型类型为“其他”,进入复核".to_string()); } // 低置信:进入复核 if confidence anyhow::Result { let dir = tauri::api::path::app_data_dir(&tauri::Config::default())?; std::fs::create_dir_all(&dir)?; Ok(dir.join("sms-tagging-officer.sqlite")) } pub fn connect() -> anyhow::Result { let p = db_path()?; Ok(Connection::open(p)?) } pub fn migrate(conn: &Connection) -> anyhow::Result { let sql = include_str!("migrations.sql"); conn.execute_batch(sql)?; Ok(()) } ``` #### `src-tauri/src/db/dao.rs` ``` use rusqlite::{params, Connection}; use serde_json::Value; use crate::domain::dto::{FinalLabel}; use crate::domain::enums::{Industry, SmsType}; #[derive(Debug, Clone)] pub struct MessageRow { pub id: String, pub content: String, pub received_at: Option, pub sender: Option, pub phone: Option, pub source: Option, } pub fn upsert_messages(conn: &Connection, rows: &[MessageRow]) -> anyhow::Result { let tx = conn.transaction()?; let mut count = 0usize; for r in rows { tx.execute( r#"INSERT INTO messages (id, content, received_at, sender, phone, source) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(id) DO UPDATE SET content=excluded.content, received_at=excluded.received_at, sender=excluded.sender, phone=excluded.phone, source=excluded.source"#, params![r.id, r.content, r.received_at, r.sender, r.phone, r.source], )?; count += 1; } tx.commit()?; Ok(count) } pub fn upsert_label(conn: &Connection, label: &FinalLabel) -> anyhow::Result { let reasons_json = serde_json::to_string(&label.reasons)?; let signals_json = serde_json::to_string(&label.signals)?; conn.execute( r#"INSERT INTO labels ( message_id, industry, type, brand, verification_code, amount, balance, account_suffix, time_text, url, phone_in_text, confidence, reasons_json, signals_json, needs_review, rules_version, model_version, schema_version, updated_at ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, datetime('now') ) ON CONFLICT(message_id) DO UPDATE SET industry=excluded.industry, type=excluded.type, brand=excluded.brand, verification_code=excluded.verification_code, amount=excluded.amount, balance=excluded.balance, account_suffix=excluded.account_suffix, time_text=excluded.time_text, url=excluded.url, phone_in_text=excluded.phone_in_text, confidence=excluded.confidence, reasons_json=excluded.reasons_json, signals_json=excluded.signals_json, needs_review=excluded.needs_review, rules_version=excluded.rules_version, model_version=excluded.model_version, schema_version=excluded.schema_version, updated_at=datetime('now')"#, params![ label.message_id, industry_to_str(&label.industry), type_to_str(&label.sms_type), label.entities.brand, label.entities.verification_code, label.entities.amount, label.entities.balance, label.entities.account_suffix, label.entities.time_text, label.entities.url, label.entities.phone_in_text, label.confidence, reasons_json, signals_json, if label.needs_review { 1 } else { 0 }, label.rules_version, label.model_version, label.schema_version, ], )?; Ok(()) } pub fn get_label_json(conn: &Connection, message_id: &str) -> anyhow::Result> { let mut stmt = conn.prepare(r#"SELECT industry, type, brand, verification_code, amount, balance, account_suffix, time_text, url, phone_in_text, confidence, reasons_json, signals_json, needs_review, rules_version, model_version, schema_version FROM labels WHERE message_id=?1"#)?; let mut rows = stmt.query(params![message_id])?; if let Some(r) = rows.next()? { let reasons_json: String = r.get(11)?; let signals_json: String = r.get(12)?; let v = serde_json::json!({ "message_id": message_id, "industry": r.get::(0)?, "type": r.get::(1)?, "entities": { "brand": r.get::>(2)?, "verification_code": r.get::>(3)?, "amount": r.get::>(4)?, "balance": r.get::>(5)?, "account_suffix": r.get::>(6)?, "time_text": r.get::>(7)?, "url": r.get::>(8)?, "phone_in_text": r.get::>(9)?, }, "confidence": r.get::(10)?, "reasons": serde_json::from_str::(&reasons_json).unwrap_or(Value::Array(vec![])), "signals": serde_json::from_str::(&signals_json).unwrap_or(Value::Object(Default::default())), "needs_review": r.get::(13)? == 1, "rules_version": r.get::(14)?, "model_version": r.get::(15)?, "schema_version": r.get::(16)?, }); return Ok(Some(v)); } Ok(None) } pub fn insert_audit_log(conn: &Connection, message_id: &str, operator: &str, before_json: &Value, after_json: &Value) -> anyhow::Result { conn.execute( r#"INSERT INTO audit_logs (message_id, operator, before_json, after_json) VALUES (?1, ?2, ?3, ?4)"#, params![ message_id, operator, before_json.to_string(), after_json.to_string() ], )?; Ok(()) } fn industry_to_str(i: &Industry) -> &'static str { match i { Industry::Finance => "金融", Industry::General => "通用", Industry::Gov => "政务", Industry::Channel => "渠道", Industry::Internet => "互联网", Industry::Other => "其他", } } fn type_to_str(t: &SmsType) -> &'static str { match t { SmsType::Otp => "验证码", SmsType::Transaction => "交易提醒", SmsType::BillCollect => "账单催缴", SmsType::InsuranceRenew => "保险续保", SmsType::LogisticsPickup => "物流取件", SmsType::AccountChange => "会员账号变更", SmsType::GovNotice => "政务通知", SmsType::RiskAlert => "风险提示", SmsType::Marketing => "营销推广", SmsType::Other => "其他", } } ``` --- ### 7) 批处理队列(并发/超时/重试/不卡 UI)+ Tauri Commands #### `src-tauri/src/batch/worker.rs` ``` use std::sync::{Arc, Mutex}; use tokio::sync::mpsc; use serde_json::Value; use crate::{db, rules, providers::provider::{Provider, ClassifyPayload}, fusion}; use crate::infra::log::append_error_log; #[derive(Debug, Clone)] pub struct BatchOptions { pub only_unlabeled: bool, pub only_needs_review: bool, pub max_retries: u8, } #[derive(Debug, Clone)] pub struct BatchProgress { pub total: usize, pub done: usize, pub failed: usize, pub current_id: Option, } pub struct BatchState { pub running: bool, pub progress: BatchProgress, } pub type SharedBatchState = Arc>; pub async fn run_batch( app: tauri::AppHandle, provider: Arc, message_ids: Vec, options: BatchOptions, state: SharedBatchState, ) -> anyhow::Result { { let mut s = state.lock().unwrap(); s.running = true; s.progress = BatchProgress { total: message_ids.len(), done: 0, failed: 0, current_id: None }; } let (tx, mut rx) = mpsc::channel::)>(64); // worker producer:并发投递,每条短信独立重试 for id in message_ids.clone() { let txc = tx.clone(); let prov = provider.clone(); let appc = app.clone(); tokio::spawn(async move { let res = process_one(appc, prov, &id, &options).await; let _ = txc.send((id, res)).await; }); } drop(tx); while let Some((id, res)) = rx.recv().await { let mut emit_payload = serde_json::json!({"id": id, "ok": true}); match res { Ok(label_json) => { emit_payload["label"] = label_json; let mut s = state.lock().unwrap(); s.progress.done += 1; s.progress.current_id = None; } Err(e) => { append_error_log(format!("batch item failed id={} err={}", id, e)).ok(); emit_payload["ok"] = serde_json::json!(false); emit_payload["error"] = serde_json::json!(e.to_string()); let mut s = state.lock().unwrap(); s.progress.failed += 1; s.progress.done += 1; s.progress.current_id = None; } } // 推送进度到前端 let s = state.lock().unwrap().progress.clone(); let _ = app.emit_all("batch_progress", serde_json::json!({ "total": s.total, "done": s.done, "failed": s.failed, "current_id": s.current_id, "event": emit_payload })); } { let mut s = state.lock().unwrap(); s.running = false; } Ok(()) } async fn process_one( _app: tauri::AppHandle, provider: Arc, message_id: &str, options: &BatchOptions, ) -> anyhow::Result { let conn = db::connect()?; db::migrate(&conn)?; // 查询 content let mut stmt = conn.prepare("SELECT content FROM messages WHERE id=?1")?; let content: String = stmt.query_row([message_id], |r| r.get(0))?; // 过滤:only_unlabeled / only_needs_review if options.only_unlabeled { let mut s2 = conn.prepare("SELECT COUNT(1) FROM labels WHERE message_id=?1")?; let cnt: i64 = s2.query_row([message_id], |r| r.get(0))?; if cnt > 0 { return Ok(serde_json::json!({"skipped": true})); } } if options.only_needs_review { let mut s3 = conn.prepare("SELECT needs_review FROM labels WHERE message_id=?1")?; let v = s3.query_row([message_id], |r| r.get::(0)).ok(); if v != Some(1) { return Ok(serde_json::json!({"skipped": true})); } } let rule = rules::rule_engine::apply_rules(&content); // 规则强命中:直接融合(rule_only) if rule.hit && rule.industry.is_some() && rule.sms_type.is_some() { let final_label = fusion::decision::fuse(message_id, &rule, None); crate::db::dao::upsert_label(&conn, &final_label)?; return Ok(crate::db::dao::get_label_json(&conn, message_id)?.unwrap()); } // 模型层:重试 let mut last_err: Option = None; for _ in 0..=options.max_retries { let payload = ClassifyPayload { message_id: message_id.to_string(), content: content.clone(), rule: rule.clone(), schema_version: crate::domain::schema::SCHEMA_VERSION.to_string(), rules_version: crate::domain::schema::RULES_VERSION.to_string(), }; match provider.classify(payload).await { Ok(mo) => { let final_label = fusion::decision::fuse(message_id, &rule, Some(&mo)); crate::db::dao::upsert_label(&conn, &final_label)?; return Ok(crate::db::dao::get_label_json(&conn, message_id)?.unwrap()); } Err(e) => last_err = Some(e), } } Err(last_err.unwrap_or_else(|| anyhow::anyhow!("unknown classify error"))) } ``` #### `src-tauri/src/commands.rs` ``` use std::{path::PathBuf, sync::{Arc, Mutex}}; use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{db, db::dao::{MessageRow, upsert_messages, get_label_json, insert_audit_log}, providers::llama_cpp::LlamaCppProvider, providers::provider::Provider, batch::{worker, worker::{SharedBatchState, BatchState, BatchOptions}}}; #[derive(Debug, Deserialize)] pub struct ImportRequest { pub rows: Vec, } #[derive(Debug, Deserialize)] pub struct MessageRowReq { pub id: String, pub content: String, pub received_at: Option, pub sender: Option, pub phone: Option, pub source: Option, } #[derive(Debug, Serialize)] pub struct ImportResponse { pub inserted: usize, } #[tauri::command] pub fn db_init() -> Result { let conn = db::connect().map_err(|e| e.to_string())?; db::migrate(&conn).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn import_messages(req: ImportRequest) -> Result { let conn = db::connect().map_err(|e| e.to_string())?; db::migrate(&conn).map_err(|e| e.to_string())?; let rows: Vec = req.rows.into_iter().map(|r| MessageRow { id: r.id, content: r.content, received_at: r.received_at, sender: r.sender, phone: r.phone, source: r.source, }).collect(); let inserted = upsert_messages(&conn, &rows).map_err(|e| e.to_string())?; Ok(ImportResponse { inserted }) } #[tauri::command] pub fn get_label(message_id: String) -> Result, String> { let conn = db::connect().map_err(|e| e.to_string())?; db::migrate(&conn).map_err(|e| e.to_string())?; get_label_json(&conn, &message_id).map_err(|e| e.to_string()) } #[derive(Debug, Deserialize)] pub struct SaveReviewRequest { pub message_id: String, pub operator: String, pub after: Value, } #[tauri::command] pub fn save_review(req: SaveReviewRequest) -> Result { let conn = db::connect().map_err(|e| e.to_string())?; db::migrate(&conn).map_err(|e| e.to_string())?; let before = get_label_json(&conn, &req.message_id).map_err(|e| e.to_string())? .unwrap_or(Value::Null); // 直接写 labels:这里复用 JSON 写入策略(简化:前端传字段齐全) // 生产版可改为结构体反序列化,进一步强校验 let a = &req.after; conn.execute( r#"INSERT INTO labels ( message_id, industry, type, brand, verification_code, amount, balance, account_suffix, time_text, url, phone_in_text, confidence, reasons_json, signals_json, needs_review, rules_version, model_version, schema_version, updated_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, datetime('now')) ON CONFLICT(message_id) DO UPDATE SET industry=excluded.industry, type=excluded.type, brand=excluded.brand, verification_code=excluded.verification_code, amount=excluded.amount, balance=excluded.balance, account_suffix=excluded.account_suffix, time_text=excluded.time_text, url=excluded.url, phone_in_text=excluded.phone_in_text, confidence=excluded.confidence, reasons_json=excluded.reasons_json, signals_json=excluded.signals_json, needs_review=excluded.needs_review, rules_version=excluded.rules_version, model_version=excluded.model_version, schema_version=excluded.schema_version, updated_at=datetime('now')"#, rusqlite::params![ req.message_id, a["industry"].as_str().unwrap_or("其他"), a["type"].as_str().unwrap_or("其他"), a["entities"]["brand"].as_str(), a["entities"]["verification_code"].as_str(), a["entities"]["amount"].as_f64(), a["entities"]["balance"].as_f64(), a["entities"]["account_suffix"].as_str(), a["entities"]["time_text"].as_str(), a["entities"]["url"].as_str(), a["entities"]["phone_in_text"].as_str(), a["confidence"].as_f64().unwrap_or(0.5), a["reasons"].to_string(), a["signals"].to_string(), if a["needs_review"].as_bool().unwrap_or(false) { 1 } else { 0 }, a["rules_version"].as_str().unwrap_or("1.0.0"), a["model_version"].as_str().unwrap_or("manual"), a["schema_version"].as_str().unwrap_or("1.0.0"), ], ).map_err(|e| e.to_string())?; insert_audit_log(&conn, &req.message_id, &req.operator, &before, &req.after).map_err(|e| e.to_string())?; Ok(()) } #[derive(Debug, Deserialize)] pub struct ProviderConfig { pub sidecar_path: String, pub model_path: String, pub threads: u32, pub max_concurrency: usize, pub timeout_ms: u64, } #[tauri::command] pub async fn start_batch( app: tauri::AppHandle, provider_cfg: ProviderConfig, message_ids: Vec, only_unlabeled: bool, only_needs_review: bool, ) -> Result { let provider = LlamaCppProvider::new( PathBuf::from(provider_cfg.sidecar_path), PathBuf::from(provider_cfg.model_path), provider_cfg.threads, provider_cfg.max_concurrency, provider_cfg.timeout_ms, ); let provider_arc: Arc = Arc::new(provider); let state: SharedBatchState = app.state::().inner().clone(); let opts = BatchOptions { only_unlabeled, only_needs_review, max_retries: 1, }; worker::run_batch(app, provider_arc, message_ids, opts, state) .await .map_err(|e| e.to_string()) } ``` #### `src-tauri/src/main.rs` ``` #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod commands; mod domain; mod infra; mod db; mod rules; mod providers; mod fusion; mod batch; use std::sync::{Arc, Mutex}; use batch::worker::{SharedBatchState, BatchState, BatchProgress}; fn main() { let initial_state: SharedBatchState = Arc::new(Mutex::new(BatchState { running: false, progress: BatchProgress { total: 0, done: 0, failed: 0, current_id: None }, })); tauri::Builder::default() .manage(initial_state) .invoke_handler(tauri::generate_handler![ commands::db_init, commands::import_messages, commands::get_label, commands::save_review, commands::start_batch, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } ``` #### `src-tauri/Cargo.toml` ``` [package] name = "sms-tagging-officer" version = "0.1.0" edition = "2021" [build-dependencies] tauri-build = { version = "1.5", features = [] } [dependencies] tauri = { version = "1.5", features = ["path-all", "fs-all", "shell-open"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" rusqlite = { version = "0.31", features = ["bundled"] } tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "time", "process", "sync"] } anyhow = "1.0" regex = "1.10" async-trait = "0.1" ``` --- ### 8) 前端页面与核心组件(导入 / 批处理 / 列表过滤 / 复核抽屉 / 导出 / 设置) #### `apps/desktop/src/api/tauri.ts` ``` import { invoke } from "@tauri-apps/api/tauri"; import type { LabelResult } from "./schema"; export async function dbInit() { return invoke("db_init"); } export async function importMessages(rows: any[]) { return invoke("import_messages", { req: { rows } }); } export async function getLabel(messageId: string) { return invoke("get_label", { messageId }); } export async function saveReview(payload: { message_id: string; operator: string; after: any }) { return invoke("save_review", { req: payload }); } export async function startBatch(args: { provider_cfg: any; message_ids: string[]; only_unlabeled: boolean; only_needs_review: boolean; }) { return invoke("start_batch", args); } ``` #### `apps/desktop/src/api/types.ts` ``` export type MessageRow = { id: string; content: string; received_at?: string | null; sender?: string | null; phone?: string | null; source?: string | null; }; ``` #### `apps/desktop/src/stores/settings.ts` ``` import { defineStore } from "pinia"; export const useSettingsStore = defineStore("settings", { state: () => ({ operator: "default", llamaSidecarPath: "", modelPath: "", threads: 4, maxConcurrency: 2, timeoutMs: 15000, }), actions: { load() { const raw = localStorage.getItem("sms_officer_settings"); if (raw) Object.assign(this.$state, JSON.parse(raw)); }, save() { localStorage.setItem("sms_officer_settings", JSON.stringify(this.$state)); }, }, }); ``` #### `apps/desktop/src/stores/batch.ts` ``` import { defineStore } from "pinia"; export const useBatchStore = defineStore("batch", { state: () => ({ total: 0, done: 0, failed: 0, lastEvent: null as any, running: false, }), actions: { reset() { this.total = 0; this.done = 0; this.failed = 0; this.lastEvent = null; this.running = false; }, }, }); ``` #### `apps/desktop/src/router.ts` ``` import { createRouter, createWebHashHistory } from "vue-router"; import ImportPage from "./pages/ImportPage.vue"; import BatchPage from "./pages/BatchPage.vue"; import ListPage from "./pages/ListPage.vue"; import ExportPage from "./pages/ExportPage.vue"; import SettingsPage from "./pages/SettingsPage.vue"; export const router = createRouter({ history: createWebHashHistory(), routes: [ { path: "/", redirect: "/import" }, { path: "/import", component: ImportPage }, { path: "/batch", component: BatchPage }, { path: "/list", component: ListPage }, { path: "/export", component: ExportPage }, { path: "/settings", component: SettingsPage }, ], }); ``` #### `apps/desktop/src/main.ts` ``` import { createApp } from "vue"; import { createPinia } from "pinia"; import App from "./App.vue"; import { router } from "./router"; createApp(App).use(createPinia()).use(router).mount("#app"); ``` #### `apps/desktop/src/App.vue` ``` 短信智标官 导入 批处理 列表复核 导出 设置 .app { display: grid; grid-template-columns: 220px 1fr; height: 100vh; } .nav { border-right: 1px solid #eee; padding: 16px; } .nav nav { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; } .main { padding: 16px; overflow: auto; } a.router-link-active { font-weight: 700; } ``` --- #### 导入页:CSV/Excel 列映射 + 写入 messages ##### `apps/desktop/src/pages/ImportPage.vue` ``` 导入数据 支持 CSV / Excel。先选择文件,再进行列映射,然后导入到本地 SQLite。 加载内置样例 导入({{ rows.length }}条) {{ rows.slice(0,3) }} {{ msg }} .row { display: flex; gap: 10px; align-items: center; margin: 10px 0; } .preview { background: #fafafa; border: 1px solid #eee; padding: 10px; } .msg { margin-top: 10px; color: #0a7; } ``` ##### `apps/desktop/src/components/ColumnMapper.vue` ``` 列映射 id {{ h }} content {{ h }} received_at (空){{ h }} sender (空){{ h }} phone (空){{ h }} source (空){{ h }} .mapper { border: 1px solid #eee; padding: 12px; border-radius: 8px; margin: 12px 0; } .grid { display: grid; grid-template-columns: 140px 1fr; gap: 8px; align-items: center; } select { width: 100%; } ``` --- #### 批处理页:进度条、失败计数、重试、增量选项 ##### `apps/desktop/src/pages/BatchPage.vue` ``` 批处理 只跑未标注 只跑 needs_review 开始 .panel { display: flex; gap: 16px; align-items: center; padding: 12px; border: 1px solid #eee; border-radius: 8px; } ``` ##### `apps/desktop/src/components/ProgressPanel.vue` ``` 总数:{{ total }},完成:{{ done }},失败:{{ failed }} 处理中… 空闲 {{ lastEvent }} .box { margin-top: 12px; border: 1px solid #eee; border-radius: 8px; padding: 12px; } .bar { height: 10px; background: #f0f0f0; border-radius: 999px; overflow: hidden; } .fill { height: 10px; background: #3b82f6; } .meta { display: flex; justify-content: space-between; margin-top: 10px; color: #333; } .event { background: #fafafa; border: 1px solid #eee; padding: 10px; margin-top: 10px; } ``` --- #### 列表页 + 复核抽屉(这里给“可跑通的核心闭环”) > 列表过滤/导出全量查询接口较长,我在 README 里给你扩展点;这版先把“导入→批处理→单条复核保存→审计落库→导出”跑通。 ##### `apps/desktop/src/pages/ListPage.vue` ``` 列表复核 输入 message_id 直接打开复核抽屉(演示闭环)。生产版在此页接入后端分页查询与过滤。 打开 {{ err }} .row { display:flex; gap:10px; align-items:center; } .err { color:#c00; margin-top:10px; } ``` ##### `apps/desktop/src/components/ReviewDrawer.vue` ``` 复核:{{ local.message_id }} 关闭 industry {{ x }} type {{ x }} confidence entities brand verification_code amount balance account_suffix time_text url phone_in_text needs_review reasons 保存 .mask { position: fixed; inset: 0; background: rgba(0,0,0,0.25); display:flex; justify-content:flex-end; } .drawer { width: 520px; height: 100%; background: #fff; padding: 14px; overflow:auto; } header { display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid #eee; padding-bottom:8px; } .field { display:grid; grid-template-columns: 140px 1fr; gap: 8px; margin: 10px 0; align-items:center; } .grid { display:grid; grid-template-columns: 140px 1fr; gap: 8px; } footer { margin-top: 12px; display:flex; justify-content:flex-end; } .primary { background:#3b82f6; color:#fff; border:none; padding: 8px 12px; border-radius: 6px; } ``` --- #### 设置页:模型路径选择 + 健康检查(这里实现为“前端填写路径 + 批处理时由后端报错”) ##### `apps/desktop/src/pages/SettingsPage.vue` ``` 设置 本机离线推理:填写 llama.cpp 可执行文件路径 与 GGUF 模型路径。 operator llama sidecar path gguf model path threads max concurrency timeout(ms) 保存 {{ msg }} .grid { display:grid; grid-template-columns: 180px 1fr; gap: 10px; align-items:center; max-width: 900px; } .row { margin-top: 12px; display:flex; gap: 10px; align-items:center; } .msg { color:#0a7; } ``` --- #### 导出页:导出 JSONL / CSV(演示版:导出 sample,生产版接入后端查询并写文件) ##### `apps/desktop/src/pages/ExportPage.vue` ``` 导出 演示版:导出样例 JSONL。生产版:增加后端 export_labels(filters, format) 并写入用户选择路径。 下载样例 JSONL ``` --- #### 前端依赖:`package.json`(最小可跑) ``` { "name": "sms-tagging-officer", "private": true, "packageManager": "pnpm@9.0.0", "scripts": { "dev": "pnpm -C apps/desktop dev", "build": "pnpm -C apps/desktop build", "tauri:dev": "pnpm -C apps/desktop tauri dev", "tauri:build": "pnpm -C apps/desktop tauri build" }, "devDependencies": { "pnpm": "^9.0.0" } } ``` ##### `apps/desktop/package.json` ``` { "name": "sms-tagging-officer-desktop", "private": true, "scripts": { "dev": "vite", "build": "vite build", "tauri": "tauri" }, "dependencies": { "@tauri-apps/api": "^1.5.0", "pinia": "^2.1.7", "vue": "^3.4.0", "vue-router": "^4.2.5", "papaparse": "^5.4.1", "xlsx": "^0.18.5" }, "devDependencies": { "@tauri-apps/cli": "^1.5.0", "@vitejs/plugin-vue": "^5.0.0", "typescript": "^5.3.3", "vite": "^5.0.0" } } ``` ##### `apps/desktop/src/utils/sample.ts` ``` export function buildSampleRows() { return [ { id:"m1", received_at:"2026-02-10 10:01:00", sender:"中国银行", phone:"95566", source:"sample", content:"【中国银行】您尾号1234卡于2026-02-10 09:58消费58.20元,余额1020.55元。" }, { id:"m2", received_at:"2026-02-10 10:02:00", sender:"支付宝", phone:"95188", source:"sample", content:"【支付宝】验证码 493821,用于登录验证,5分钟内有效。" }, { id:"m3", received_at:"2026-02-10 10:03:00", sender:"顺丰速运", phone:"95338", source:"sample", content:"【顺丰】快件已到达XX驿站,取件码 662913,请于18:00前取走。" }, { id:"m4", received_at:"2026-02-10 10:04:00", sender:"12345", phone:"12345", source:"sample", content:"【12345政务】您反映的问题已受理,查询进度请访问 https://gov.example.cn/track" }, { id:"m5", received_at:"2026-02-10 10:05:00", sender:"某运营商", phone:"10086", source:"sample", content:"您本月话费账单已出,应缴 89.50 元,逾期将影响服务。" }, { id:"m6", received_at:"2026-02-10 10:06:00", sender:"平安保险", phone:"95511", source:"sample", content:"【平安】您的保单将于2026-03-01到期,请及时续保,详询4008000000。" }, { id:"m7", received_at:"2026-02-10 10:07:00", sender:"某电商", phone:"1069xxxx", source:"sample", content:"【京东】会员账号绑定手机号变更成功,如非本人操作请致电950618。" }, { id:"m8", received_at:"2026-02-10 10:08:00", sender:"某平台", phone:"1069xxxx", source:"sample", content:"【美团】本店新客立减券已到账,点击 http://promo.example.com 立即使用。" }, { id:"m9", received_at:"2026-02-10 10:09:00", sender:"公安反诈", phone:"12110", source:"sample", content:"【反诈中心】警惕冒充客服退款诈骗,任何验证码均不要透露。" }, { id:"m10", received_at:"2026-02-10 10:10:00", sender:"未知", phone:"unknown", source:"sample", content:"您有一笔订单待处理,请联系 13800138000 获取详情。" } ]; } ``` --- ### 9) README:运行、打包、离线分发、自测与验收点 #### `README.md` ``` # 短信智标官(SMS Tagging Officer) 离线桌面软件:对几千条短信进行“行业大类 + 类型”两层标签、实体抽取与可解释 reasons 输出。 推理完全离线:llama.cpp + GGUF 模型文件(用户在设置页选择路径)。 数据落地:SQLite(messages / labels / audit_logs),支持导入、批处理、复核、导出。 ## 1. 功能边界(固定枚举) 一级行业:金融、通用、政务、渠道、互联网、其他 二级类型:验证码、交易提醒、账单催缴、保险续保、物流取件、会员账号变更、政务通知、风险提示、营销推广、其他 实体字段:brand、verification_code、amount、balance、account_suffix、time_text、url、phone_in_text(缺失填 null) 每条输出稳定 JSON,必须包含: confidence、reasons、rules_version、model_version、schema_version、needs_review ## 2. 本地推理集成方式 默认 Provider:llama.cpp sidecar(可执行文件随应用打包/或由用户指定路径) 后续可扩展 Provider:比如其他本地推理、甚至远端(如果你未来允许联网) Provider 抽象:classify(payload) -> ModelOutput ## 3. 环境准备(开发) - Node.js 18+ - pnpm 9+ - Rust stable - Tauri CLI ```bash pnpm i pnpm tauri:dev ## 4. llama.cpp 与模型文件准备(运行期离线) 你需要准备: 1. llama.cpp 可执行文件:llama-cli (Windows: llama-cli.exe) 或 llama 1. GGUF 模型文件(推荐小模型 q4/q5 量化) 把二进制放在本机任意目录,模型放在本机任意目录。 在应用「设置页」填写路径后,批处理页即可运行。 ## 5. 一键自测步骤(验收闭环) 1. 打开应用 -> 导入页 -> 点击「加载内置样例」-> 导入(10条) 1. 设置页:填写 sidecar_path 与 model_path 1. 批处理页:勾选「只跑未标注」-> 开始 1. 列表复核页:输入 m1/m2... 打开抽屉,修改字段 -> 保存 1. 验证点: - labels 表存在 message_id 对应记录 - audit_logs 表新增一条记录(before/after 有差异) - 输出 JSON 字段齐全(entities 所有字段都有,缺失为 null) - 冲突样本进入 needs_review(例如包含金额/链接却被模型判“其他”时) ## 6. 如何验证输出 JSON 约束 仓库 assets/json_schema_output.json 提供 JSON Schema。 你可以用任意 JSON Schema 校验工具对导出的 JSONL 逐行验证。 ## 7. 打包与分发 ``` pnpm tauri:build ``` 产物在: - Windows: src-tauri/target/release/bundle/msi 或 nsis - macOS: .app / dmg - Linux: AppImage / deb ### 分发注意 - 运行期完全离线:SQLite 文件在应用数据目录,模型与 sidecar 在本机路径。 - 若希望随包附带 llama.cpp 二进制: - 将 sidecar 放入 src-tauri/bin/ 并在 tauri.conf.json 配 sidecar - 设置页默认填入 sidecar 的 resolved 路径(扩展点) ## 8. 扩展点(生产完善) - 列表页接入分页查询与过滤:按 industry/type/needs_review/confidence 区间/含链接/含验证码/含金额等 - 导出接口:按筛选条件导出 CSV/JSONL,支持仅导出已复核或全量 - health check:在设置页调用后端 command 执行一次 `llama -m ... -p "ping"` 并校验 JSON - 模型输出更严格:加入 JSON schema 约束解码(可在 llama.cpp 参数或 prompt 中增强) # ```` --- ## 你要的“评价”结论(基于可交付性) - 这套框架在离线桌面交付里属于 **能稳定跑通、能验收、能扩展** 的形态:规则层先把高确定性短信打穿,模型层只收灰区,融合层把冲突聚焦到 needs_review 队列,复核动作全审计,最终输出稳定 JSON,满足报表与治理的回放要求。 - 关键风险点集中在两处:**模型输出稳定性**(严格 JSON/枚举约束)与 **批处理吞吐**(并发、超时、重试、不卡 UI)。这版工程把两者都“工程化地钉住”了:sidecar 调用 + timeout + semaphore + 事件推送;prompt 强约束 + 输出截取 + 失败落盘。 - 后续要上生产强度,你主要补三块:列表查询/过滤与导出接口、设置页 health check、规则字典与企业自定义(品牌/机构词典、金融/政务更细的 signals),这些都已经留好了扩展点。 --- 如果你希望我把“列表页的分页查询 + 过滤条件(行业/类型/needs_review/置信度区间/含链接/含验证码/含金额)+ 导出 CSV/JSONL(仅复核/全量)”也补成完整可跑代码,我可以在现有骨架上继续把 `query_messages(filters)`、`export_labels(filters, format, reviewed_only)` 两个后端 command 和前端 FilterBar + MessageTable 的整套链路补齐。 ``` ```

资源下载

更多资源
Mario

Mario

马里奥是站在游戏界顶峰的超人气多面角色。马里奥靠吃蘑菇成长,特征是大鼻子、头戴帽子、身穿背带裤,还留着胡子。与他的双胞胎兄弟路易基一起,长年担任任天堂的招牌角色。

Spring

Spring

Spring框架(Spring Framework)是由Rod Johnson于2002年提出的开源Java企业级应用框架,旨在通过使用JavaBean替代传统EJB实现方式降低企业级编程开发的复杂性。该框架基于简单性、可测试性和松耦合性设计理念,提供核心容器、应用上下文、数据访问集成等模块,支持整合Hibernate、Struts等第三方框架,其适用范围不仅限于服务器端开发,绝大多数Java应用均可从中受益。

Rocky Linux

Rocky Linux

Rocky Linux(中文名:洛基)是由Gregory Kurtzer于2020年12月发起的企业级Linux发行版,作为CentOS稳定版停止维护后与RHEL(Red Hat Enterprise Linux)完全兼容的开源替代方案,由社区拥有并管理,支持x86_64、aarch64等架构。其通过重新编译RHEL源代码提供长期稳定性,采用模块化包装和SELinux安全架构,默认包含GNOME桌面环境及XFS文件系统,支持十年生命周期更新。

Sublime Text

Sublime Text

Sublime Text具有漂亮的用户界面和强大的功能,例如代码缩略图,Python的插件,代码段等。还可自定义键绑定,菜单和工具栏。Sublime Text 的主要功能包括:拼写检查,书签,完整的 Python API , Goto 功能,即时项目切换,多选择,多窗口等等。Sublime Text 是一个跨平台的编辑器,同时支持Windows、Linux、Mac OS X等操作系统。

用户登录
用户注册