diff --git a/CLAUDE.md b/CLAUDE.md index d07dc8f..22ddd52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,17 +2,17 @@ ## 目录结构与职责(硬性约束) -> **`biz/` 是泛化占位名,不是固定目录命名**。表格中 `biz/` 代表「业务模块目录」,各项目必须按自身业务命名替换(本项目为 `kb/`),禁止新项目照抄 `biz/`;`ui-src/`、`data/`、`workspace/` 亦为本项目目录名,各项目按自身命名。 +> **`biz/` 是泛化占位名,不是固定目录命名**。表格中 `biz/` 代表「业务模块目录」,各项目必须按自身业务命名替换(本项目即 `biz/`),禁止新项目照抄 `biz/`;`ui-src/`、`data/`、`workspace/` 亦为本项目目录名,各项目按自身命名。 | 目录 | 职责 | 强约束 | |---|---|---| -| common/ | 通用层:HTTP 服务与鉴权中间件、文件解析(parser + pdf/docx/html/text)、中文分词、向量 JSON、DAO 基类、查询缓存、协程池封装(pool.go) | 不得依赖业务模块包(仅 pool.go 依赖 `biz/consts` 取池默认值,既成事实);新增跨模块通用能力放这里 | +| common/ | 通用层:HTTP 服务与鉴权中间件、文件解析(parser + pdf/docx/html/text)、中文分词、向量 JSON、DAO 基类、查询缓存、协程池封装 | 不得依赖业务模块包;新增跨模块通用能力放这里 | | biz/consts/ | 常量集中地:表名(table_name.go)、状态(status.go)、内容类型、默认参数与各协程池默认大小(consts.go) | 业务常量一律在此集中,禁止散落 magic number;新增池默认大小在此定义 | -| biz/model/ | entity(表结构,与 DAO 一一对应)、dto(请求/响应结构,`g.Meta` 内嵌定义路由)、domain(领域模型,如 RiskSummary / Citation) | entity 只做表映射,不带业务逻辑;dto 是 controller 与 HTTP 的唯一出入口 | +| biz/model/ | entity(表结构,与 DAO 一一对应)、dto(请求/响应结构,`g.Meta` 内嵌定义路由)、domain(领域模型:跨表聚合与服务层组装值,可被 dto/entity 引用) | entity 只做表映射,不带业务逻辑;dto 是 controller 与 HTTP 的唯一出入口;domain 收纳不属于 dto 也不属于 entity 的类型(见下) | | biz/dao/ | 单表数据访问,每表一个文件 | 无业务逻辑;查询经 base_dao 缓存 | | biz/service/ | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao、LLM 编排 | 不直接写 HTTP 响应(例外见下);并行任务走 common 协程池 | | biz/controller/ | 接口层:接收参数、调用 service、组装返回值 | 见「分层职责规范」;禁止调用 dao | -| 前端目录 | Vue 3 + Element Plus 前端(本项目 `ui-src/`) | 开发用 vite 代理,生产构建产物 `dist` 由后端托管 | +| 前端目录 | 前台 `ui-src/` 为 uni-app (Vue 3) 多端工程(Android/iOS/平板/微信小程序/H5);后台管理端规划中(未建工程) | 前台开发用 vite 代理,生产构建产物 `dist` 由后端托管;多端工程差异见技术设计.md | | 运行时数据目录 | SQLite 库、上传/解析文件(本项目 `data/` `workspace/`) | 不提交 git;删除即丢失数据,改动前先确认 | ## 分层职责规范(硬性要求) @@ -21,27 +21,42 @@ | 层 | 目录 | 职责 | 禁止 | |---|---|---|---| -| controller | biz/controller | 接收参数(依赖 DTO `v` tag 自动校验)、调用 service、组装返回值 | 直接调用 dao;手写业务规则校验(库表依赖/跨字段,应下沉 service);文件 IO;状态流转;跨表数据组装 | -| service | biz/service | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao | 直接写 HTTP 响应(例外见下) | -| dao | biz/dao | 单表数据访问,每表一个文件 | 业务逻辑 | +| controller | biz/controller | 接收 dto 请求参数(依赖 DTO `v` tag 自动校验)调用 service,原样返回 service 结果(返回类型与 service 一致,即 dto);**传参方式:整个 `*dto.XxxReq` 直接传给 service,禁止从 dto 拆出多个属性逐个传参** | 直接调用 dao;任何组装/映射/字段搬运;手写业务规则校验(库表依赖/跨字段,应下沉 service);文件 IO;状态流转;跨表数据组装 | +| service | biz/service | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao;只允许返回 dto 类型(返回与 controller 输出一致的 `*dto.XxxRes`),派生值(如 scene_name/node_count)在 service 用 dto 组装 | 直接写 HTTP 响应(例外见下);返回裸 gdb.Record 或任何非 dto 类型 | +| dao | biz/dao | 构建 SQL 并执行;行→结构体转换在 dao 内部用 GoFrame 自带方法(`Record.Struct` / `Result.Structs`,按 `orm` tag)完成,对外只允许返回 entity(或单值如 int/map) | 业务逻辑;返回裸 gdb.Record——**裸 gdb.Record 不允许作为任何分层方法的返回值**(含 service 内事务读),转换只发生在 dao 内部,不外泄 | **例外**:SSE 流式响应、HTML/文件导出等"直接写响应体"的场景由 controller 完成——这是"值返回"的流式形式,事件序列化、心跳属 HTTP 协议职责,保留在 controller。 +**分层锚定原则(controller 反向锚定,防跑偏)**:controller 只透传 ⇒ 接口返回类型以 dto 为准 ⇒ service 返回类型被 dto 锁死 ⇒ dao 输出被 entity 锁死。任何一层若出现"为下一层做数据搬运"(controller 映射 service 结果、service 逐键取裸 Record 字段),即违反本原则,应向上收敛:转换在 dao 内部(Record→entity)、组装在 service(entity→dto)、透传在 controller。 + +**教训(此前偏离原因,开发时引以为戒)**: +1. 自定义中转层不得替代 dto:domain 只收"不进 HTTP 出入参"的纯领域值(如结算输入 SettleState/结算结果 FinalSettle);凡出现在接口出入参中的类型一律用 dto,由 service 直接产出——否则 controller 被迫承担映射,违反"controller 只透传" +2. dao 必须完成行→结构体转换:裸 gdb.Record 的 string 键取值是魔法值,拼错列名编译期不报错,且会把键取值扩散到 service/controller;GoFrame 自带 `Record.Struct`/`Result.Structs`(按 `orm` tag)即转换手段 +3. 新分层先对齐框架惯例:GoFrame 原生分层即 dao 转 entity / service 返回 dto / controller 薄透传,自定义设计前先核对框架默认范式 + ## 分层文件对齐与代码模式(硬性要求) -- 每张业务表对应一组 `entity / dao / service / controller / dto` 文件,数量严格对齐;虚拟表(向量 vec0 / FTS5)不建独立分层文件,由主表 dao 统一管理 +- 每张业务表对应一组 `entity / dao / service / controller / dto` 文件,数量严格对齐(核验方式:每层目录文件数 = 分层表数,分层表数 = 总表数 − 豁免表数);虚拟表(向量 vec0 / FTS5)与**流水/记录类表(如 point_log)豁免分层对齐**:不建任何独立分层文件(含 entity/dao),建表由主表 dao 统一管理(同虚拟表模式),由使用方 service 事务内直写,禁止为只写不读的审计表造分层门面;无任何读写引用的死表连表带分层整套删除,启动时 DROP 库内残留表与代码保持一致 +- **非表文件一律不进业务分层目录**:路由注册与中间件装配、表初始化列表(建表 + 死表 DROP)直接写在 `main.go`;鉴权等跨模块通用能力放 `common/`;跨表业务流程归入所属表文件(如闯关 Choose 属 level 表)——分层目录出现非表文件即违反对齐,禁止以非表名开独立分层文件 - **不建 parser/rag 等技术目录**:纯技术能力(文档解析、中文分词、向量序列化)平铺在 `common/`;业务编排(分块、检索、工作流)归入对应 service 文件 - entity:每文件一张表,`orm` 标签与列名一致,时间字段用 `*gtime.Time`,只做表映射 -- dao:单例 `var Xxx = &xxxDao{}`,`init()` 内 `CREATE TABLE IF NOT EXISTS` + 索引 + 迁移;通用 CRUD 复用 `common/base_dao.go`(InsertAndReturnId / GetOneByPk / UpdateByPk / DeleteByPk) -- controller:结构体名决定路由前缀(如 `dataset` → `/dataset`),接口定义在 dto(`g.Meta` 携带 path/method/summary) +- dto:请求/响应结构,`g.Meta` 内嵌定义路由;只描述 HTTP 出入参,不承载领域逻辑 +- **domain(目录 `biz/model/domain/`,package domain)**:仅收纳**不进 HTTP 出入参**的纯领域值(如结算输入 SettleState/结算结果 FinalSettle,service 内部流转 + 单测使用);判断标准:类型是否出现在接口出入参中——出现即用 dto。entity 对表、dto 对 HTTP、domain 对纯领域;service 返回 dto(允许 import dto),dao 返回 entity(禁止外泄 gdb.Record) +- dao:单例 `var Xxx = &xxxDao{}`,`init()` 内 `CREATE TABLE IF NOT EXISTS` + 索引 + 迁移;通用 CRUD 复用 `common/base_dao.go`(InsertAndReturnId / GetOneByPk / UpdateByPk) +- controller:路由**反射注册**(`common.BindController`),path/method 唯一来源为 dto 的 `g.Meta`(携带 path/method/summary);新增接口只需写 dto + controller 方法,禁止在 main.go 手动逐条注册;跨组方法以 `H5` 前缀命名(h5 组只注册 H5 开头方法,admin 组跳过) - **接口只允许 GET / POST**:写操作传 JSON body(或 multipart),读操作走 query params;无 PUT/DELETE - dao 查询缓存:查询用 `gdb.CacheOption`(TTL 来自配置),**写操作后必须清对应缓存**,否则出现"库里已改、查询还是旧值" -## 并发规范(grpool 协程池) +## 错误处理规范(硬性要求) + +- 所有可能失败的调用必须显式处理返回的 error:向上返回(保留上下文用 `gerror.Wrap`/`Newf`)或记录日志,禁止 `_, _ =` 静默丢弃——吞错会掩盖故障根因,修复问题必须先定位错误路径,不得以忽略 error 换取编译通过 +- defer 关闭等无法向上返回的资源清理错误,用 `defer func() { _ = x.Close() }()` 显式声明忽略意图,禁止裸 `defer x.Close()` 隐式吞错 + +## 并发规范 - **可并行的场景**:纯 IO 任务——读查询、LLM/Embedding 调用、文件读取。SQLite 写一律回主 goroutine 串行(无 WAL 时并发写会 `database is locked`,锁定风险归零,并发只赢在 IO 等待上) -- **新增并行点的固定三处**:`common/pool.go` 加池变量(grpool 封装)→ `biz/consts` 加默认大小 → `config.yml` 的 `pool` 段加 `key: 并发度`(缺失或非法时回退默认值) -- 禁止直接用裸 `go` 启动并行工作负载,一律走 `common` 的池(池清单与默认值见 README 配置说明) +- **新增并行点的固定三处**:common 加池封装(grpool)→ `biz/consts` 加默认大小 → `config.yml` 加 `key: 并发度`(缺失或非法时回退默认值) +- 禁止直接用裸 `go` 启动并行工作负载,一律走 `common` 的池 - **防死锁**:等待链单向「主 → A池 → B池」,被等待池的任务内不得再等待任何池(会饿死 worker);池无 Wait 方法,等待用调用方 `sync.WaitGroup`,任务结果经 buffered channel 回主 goroutine - **共享状态安全(内存)**:池内任务并发执行,共享实例(service 单例、model 句柄等)只允许**只读**访问;可变字段必须在**提交池之前**由主 goroutine 一次性预置,任务内禁止写共享字段——Go map 并发写直接 `fatal error: concurrent map writes`,无锁、无降级、不可恢复,只能崩溃重启。需要可变共享状态时按优先级:① 无共享(任务内新建、buffered channel 传递结果)② 锁(`sync.Mutex`/`RWMutex`,锁内只做内存操作,LLM/DB 等 IO 放锁外)③ `sync/atomic`(仅限 int 类标量计数/标志,如 `atomic.AddInt64`,并发计数禁用普通 `++`;复合结构不要用 atomic,指针 CAS 属例外) - **锁的使用**:互斥场景唯一入口是 `common.WithLock[T any](ctx, key, expire, retries, retryInterval, fn func() (T, error)) (T, error)`——泛型回调,业务返回值经 T 原样透出给下游;内部按 config.yml 自动选择锁实现(配置了 `redis` 节点 → redis 锁,跨实例互斥,SET NX EX + token 对比删除防误删他人锁;未配置 → gcache 内存锁,单实例互斥),禁止直接用 gcache/gredis 自己实现加锁。拿不到锁(被占用,`ErrLockHeld`)最多重试 `retries` 次、每次间隔 `retryInterval`(`retries=0` 立即失败;ctx 取消/超时同样终止等待);中间件故障不重试直接返回。锁自动释放:无论 fn 成功、失败还是 panic,defer 释放。`expire` 必须 > 0(进程崩溃兜底不死锁),fn 耗时必须在 expire 前完成,fn 内禁止长耗时 IO(LLM/DB 调用);锁粒度按业务唯一键尽量小 @@ -61,7 +76,7 @@ ## 数据访问规范(硬性要求) -- **事务**:涉及多张表的增删改操作必须包数据库事务,禁止逐表裸调用。事务放 dao 层方法内,service 层负责编排;`tx.Begin` 后必须用 `defer` 防护已提交后的二次 Rollback +- **事务**:涉及多张表的增删改操作必须包数据库事务,禁止逐表裸调用。**事务必须在 service 层**(`g.DB().Transaction` 包裹与事务内写方法如 `XxxInTx`,service 持有 tx 句柄编排多表),dao 层只做单表无状态 CRUD,不持事务;`tx.Begin` 后必须用 `defer` 防护已提交后的二次 Rollback - **SQL 单表约束**:每个 SQL 只允许访问一张表,禁止 JOIN 与跨表子查询(`IN (SELECT ...)` / `EXISTS`);跨表数据一律拆为多条单表 SQL + 应用层内存组装——先取外键 id 列表,再对目标表 `IN` 查询;`IN` 参数须按 ≤100 分批(SQLite 变量数上限 999) - **禁止 N+1 查询**:禁止在循环中逐条查库。循环场景一律改为批处理——一次 `ListByXxx` 取回后按外键在内存分组 - **缓存一致性**:DAO 查询走缓存(TTL 来自 `database.cache.ttl`),写操作后必须清对应缓存 @@ -76,6 +91,14 @@ - **运行时数据与代码分离**:数据目录不提交 git;**删除即丢数据,改动前先确认** - **配置即文件**:项目配置文件为唯一配置入口(监听端口、并发度等),环境变量覆盖无效 +## 金额单位规范(硬性要求) + +- **金额一律以「分」为单位存储与传输**:所有金额字段(零售价、进价、单价、小计、总金额)在数据库与接口中使用整数分(entity/dto 字段类型 `int64`),禁止浮点元 +- **展示转换在前端**:前端展示 ÷100 转元保留两位小数,提交 ×100 转分;禁止后端做元↔分转换,后端只处理整数分 +- **金额计算**:`数量(浮点克) × 单价(分)` 后必须 `common.RoundInt` 四舍五入到整数分,禁止裸浮点累加 +- **量纲区分**:数量/库存/用量为浮点克(REAL),金额为整数分;混算时必须显式转换 +- 存量库迁移以 `PRAGMA user_version` 版本化标记,禁止重复执行 + ## 约定 - controller 方法签名固定为 `(ctx, *dto.XxxReq) (*dto.XxxRes, error)`,实例注册模式 `var Xxx = &xxx{}` @@ -83,3 +106,5 @@ - 响应组装(实体 → DTO 字段映射)在 controller 进行 - service 方法签名 ctx 开头,错误统一用 `gerror` - 编译验证:`go build ./...` +- **列表页单元格禁止换行(布局优先级)**:单元格一律单行。排布顺序:① 列宽按数据量设足(自由文本列按实际长度加宽) → ② 列多表宽时用固定列(fixed,如操作列 fixed right) → ③ 最后才对超长文本用 `show-overflow-tooltip` 省略兜底;禁止无 tooltip 的 min-width 列静默截断。全局 `admin-src/src/style.css` 的 `.el-table .cell { white-space: nowrap }` 是最终兜底,新列表页不得依赖换行展示 +- **H5 客户侧文案合规(硬性)**:H5 页面与微信模板消息面向终端消费者,软件方无医疗资质,禁止出现 诊所/开方/处方/药方/医嘱/药品/服药/诊疗 等医疗行为用语——暗示诊疗即违规;一律用中性话术:「健康打卡」「调理」「饮食禁忌提醒」「联系服务机构」等。后台管理端(admin-src)是持牌机构内部经营工具,业务词汇(药品/处方/开方)不受此限,但登录页与浏览器标题等可被搜索引擎收录的对外表面保持中性品牌名 diff --git a/config.yml b/config.yml index d21bd60..995fac8 100644 --- a/config.yml +++ b/config.yml @@ -39,7 +39,7 @@ react: # 协程池配置(各并行点并发度,缺失或 <1 时回退代码内默认值) pool: kg_extract: 8 # 知识图谱:逐 chunk LLM 抽取(增加并发数加快构建速度) - annotation_clause: 4 # 合同标注:逐条款 (recall+judge) + annotation_clause: 1 # 合同标注:逐条款 (recall+judge)。oMLX 并发串行,并发纯排队无加速,1=串行防队尾超时 annotation_dataset: 8 # 条款内:逐 dataset 召回 chat: 4 # 问答:retrieve 与 GraphEnhance 并行 chat_retrieve: 4 # 检索:vec 与 fts 并行 diff --git a/data/business.db b/data/business.db index 4c85034..3196980 100644 Binary files a/data/business.db and b/data/business.db differ diff --git a/kb/consts/table_name.go b/kb/consts/table_name.go index 62ba6ab..943e3a9 100644 --- a/kb/consts/table_name.go +++ b/kb/consts/table_name.go @@ -16,6 +16,10 @@ const ( TableNameContractClause = "kb_contract_clause" TableNameContractMark = "kb_contract_mark" TableNameContractRisk = "kb_contract_risk" + TableNameEvidence = "kb_evidence" + TableNameCase = "kb_case" + TableNameCaseEvidence = "kb_case_evidence" + TableNameReferenceCase = "kb_reference_case" ) // 数据库组:默认组(default)=business.db、system=system.db、chat=chat.db diff --git a/kb/controller/case_controller.go b/kb/controller/case_controller.go new file mode 100644 index 0000000..051ffa6 --- /dev/null +++ b/kb/controller/case_controller.go @@ -0,0 +1,62 @@ +package controller + +import ( + "context" + + "rag-local/kb/model/dto" + "rag-local/kb/service" +) + +type caseController struct{} + +var Case = &caseController{} + +func (c *caseController) Save(ctx context.Context, req *dto.SaveCaseReq) (*dto.SaveCaseRes, error) { + id, err := service.CaseService.Save(ctx, req) + if err != nil { + return nil, err + } + return &dto.SaveCaseRes{Id: id}, nil +} + +func (c *caseController) List(ctx context.Context, req *dto.ListCaseReq) (*dto.ListCaseRes, error) { + list, total, err := service.CaseService.List(ctx, req.Page, req.PageSize) + if err != nil { + return nil, err + } + return &dto.ListCaseRes{ + List: list, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + }, nil +} + +func (c *caseController) Detail(ctx context.Context, req *dto.GetCaseDetailReq) (*dto.GetCaseDetailRes, error) { + cse, evidences, contracts, err := service.CaseService.Detail(ctx, req.Id) + if err != nil { + return nil, err + } + return &dto.GetCaseDetailRes{Case: cse, Evidences: evidences, Contracts: contracts}, nil +} + +func (c *caseController) Delete(ctx context.Context, req *dto.DeleteCaseReq) (*dto.DeleteCaseRes, error) { + if err := service.CaseService.Delete(ctx, req.Id); err != nil { + return nil, err + } + return &dto.DeleteCaseRes{}, nil +} + +func (c *caseController) LinkEvidence(ctx context.Context, req *dto.LinkEvidenceReq) (*dto.LinkEvidenceRes, error) { + if err := service.CaseService.LinkEvidence(ctx, req.CaseId, req.EvidenceIds); err != nil { + return nil, err + } + return &dto.LinkEvidenceRes{}, nil +} + +func (c *caseController) UnlinkEvidence(ctx context.Context, req *dto.UnlinkEvidenceReq) (*dto.UnlinkEvidenceRes, error) { + if err := service.CaseService.UnlinkEvidence(ctx, req.CaseId, req.EvidenceId); err != nil { + return nil, err + } + return &dto.UnlinkEvidenceRes{}, nil +} diff --git a/kb/controller/contract_controller.go b/kb/controller/contract_controller.go index f40b1d5..e459858 100644 --- a/kb/controller/contract_controller.go +++ b/kb/controller/contract_controller.go @@ -18,7 +18,7 @@ func (c *contract) Upload(ctx context.Context, req *dto.UploadContractReq) (*dto if req.File == nil { return nil, gerror.New("请选择合同文件") } - id, err := service.AnnotationService.Upload(ctx, req.File, req.DatasetIds) + id, err := service.AnnotationService.Upload(ctx, req.File, req.DatasetIds, req.CaseId) if err != nil { return nil, err } diff --git a/kb/controller/dataset_controller.go b/kb/controller/dataset_controller.go index bb73936..94a98bc 100644 --- a/kb/controller/dataset_controller.go +++ b/kb/controller/dataset_controller.go @@ -4,7 +4,6 @@ import ( "context" "rag-local/kb/model/dto" - "rag-local/kb/model/entity" "rag-local/kb/service" ) @@ -21,19 +20,7 @@ func (c *dataset) List(ctx context.Context, _ *dto.ListDatasetReq) (*dto.ListDat } func (c *dataset) Save(ctx context.Context, req *dto.SaveDatasetReq) (*dto.SaveDatasetRes, error) { - id, err := service.DatasetService.Save(ctx, &entity.Dataset{ - Id: req.Id, - Name: req.Name, - Description: req.Description, - EmbeddingCfgId: req.EmbeddingCfgId, - ChunkSize: req.ChunkSize, - ChunkOverlap: req.ChunkOverlap, - ReactRounds: req.ReactRounds, - VecTopK: req.VecTopK, - FtsTopK: req.FtsTopK, - RerankTopK: req.RerankTopK, - RecallTopK: req.RecallTopK, - }) + id, err := service.DatasetService.Save(ctx, req) if err != nil { return nil, err } diff --git a/kb/controller/evidence_controller.go b/kb/controller/evidence_controller.go new file mode 100644 index 0000000..83d88b9 --- /dev/null +++ b/kb/controller/evidence_controller.go @@ -0,0 +1,60 @@ +package controller + +import ( + "context" + + "rag-local/kb/model/dto" + "rag-local/kb/service" + + "github.com/gogf/gf/v2/errors/gerror" +) + +type evidence struct{} + +var Evidence = &evidence{} + +func (c *evidence) Upload(ctx context.Context, req *dto.UploadEvidenceReq) (*dto.UploadEvidenceRes, error) { + if req.File == nil { + return nil, gerror.New("请选择文件") + } + id, err := service.EvidenceService.Upload(ctx, req.File, req.EvidenceType, req.Description, req.DatasetIds, req.CaseId) + if err != nil { + return nil, err + } + return &dto.UploadEvidenceRes{Id: id}, nil +} + +func (c *evidence) List(ctx context.Context, req *dto.ListEvidenceReq) (*dto.ListEvidenceRes, error) { + list, total, err := service.EvidenceService.List(ctx, req.Page, req.PageSize) + if err != nil { + return nil, err + } + return &dto.ListEvidenceRes{ + List: list, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + }, nil +} + +func (c *evidence) Detail(ctx context.Context, req *dto.GetEvidenceDetailReq) (*dto.GetEvidenceDetailRes, error) { + ev, err := service.EvidenceService.Detail(ctx, req.Id) + if err != nil { + return nil, err + } + return &dto.GetEvidenceDetailRes{Evidence: ev}, nil +} + +func (c *evidence) Delete(ctx context.Context, req *dto.DeleteEvidenceReq) (*dto.DeleteEvidenceRes, error) { + if err := service.EvidenceService.Delete(ctx, req.Id); err != nil { + return nil, err + } + return &dto.DeleteEvidenceRes{}, nil +} + +func (c *evidence) Update(ctx context.Context, req *dto.UpdateEvidenceReq) (*dto.UpdateEvidenceRes, error) { + if err := service.EvidenceService.Update(ctx, req.Id, req.Description, req.DatasetIds); err != nil { + return nil, err + } + return &dto.UpdateEvidenceRes{}, nil +} diff --git a/kb/controller/reference_case_controller.go b/kb/controller/reference_case_controller.go new file mode 100644 index 0000000..6456376 --- /dev/null +++ b/kb/controller/reference_case_controller.go @@ -0,0 +1,60 @@ +package controller + +import ( + "context" + + "rag-local/kb/model/dto" + "rag-local/kb/service" + + "github.com/gogf/gf/v2/errors/gerror" +) + +type referenceCase struct{} + +var ReferenceCase = &referenceCase{} + +func (c *referenceCase) Save(ctx context.Context, req *dto.SaveReferenceCaseReq) (*dto.SaveReferenceCaseRes, error) { + id, err := service.ReferenceCaseService.Save(ctx, req) + if err != nil { + return nil, err + } + return &dto.SaveReferenceCaseRes{Id: id}, nil +} + +func (c *referenceCase) List(ctx context.Context, req *dto.ListReferenceCaseReq) (*dto.ListReferenceCaseRes, error) { + list, total, err := service.ReferenceCaseService.List(ctx, req.Page, req.PageSize) + if err != nil { + return nil, err + } + return &dto.ListReferenceCaseRes{ + List: list, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + }, nil +} + +func (c *referenceCase) Detail(ctx context.Context, req *dto.GetReferenceCaseDetailReq) (*dto.GetReferenceCaseDetailRes, error) { + rc, docs, err := service.ReferenceCaseService.Detail(ctx, req.Id) + if err != nil { + return nil, err + } + return &dto.GetReferenceCaseDetailRes{ReferenceCase: rc, Documents: docs}, nil +} + +func (c *referenceCase) Delete(ctx context.Context, req *dto.DeleteReferenceCaseReq) (*dto.DeleteReferenceCaseRes, error) { + if err := service.ReferenceCaseService.Delete(ctx, req.Id); err != nil { + return nil, err + } + return &dto.DeleteReferenceCaseRes{}, nil +} + +func (c *referenceCase) UploadDoc(ctx context.Context, req *dto.UploadReferenceCaseDocReq) (*dto.UploadReferenceCaseDocRes, error) { + if req.File == nil { + return nil, gerror.New("请选择文件") + } + if err := service.ReferenceCaseService.UploadDocument(ctx, req.Id, req.File); err != nil { + return nil, err + } + return &dto.UploadReferenceCaseDocRes{}, nil +} diff --git a/kb/dao/case_dao.go b/kb/dao/case_dao.go new file mode 100644 index 0000000..348b57b --- /dev/null +++ b/kb/dao/case_dao.go @@ -0,0 +1,100 @@ +package dao + +import ( + "context" + "database/sql" + "errors" + + "rag-local/kb/consts" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +var Case = &caseDao{} + +type caseDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB(consts.DbGroupDefault).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCase+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL DEFAULT '', + case_number TEXT NOT NULL DEFAULT '', + case_type TEXT NOT NULL DEFAULT '', + court TEXT NOT NULL DEFAULT '', + parties TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + dataset_ids TEXT NOT NULL DEFAULT '', + status INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT (datetime('now','localtime')), + updated_at DATETIME DEFAULT (datetime('now','localtime')) + )`) + if err != nil { + g.Log().Warningf(ctx, "create kb_case table failed: %v", err) + } +} + +func (d *caseDao) GetOne(ctx context.Context, id int64) (*entity.Case, error) { + var m entity.Case + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameCase).Ctx(ctx).Where("id", id).Scan(&m) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (d *caseDao) List(ctx context.Context, page, pageSize int) ([]*entity.Case, int, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + total, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameCase).Ctx(ctx).Count() + if err != nil { + return nil, 0, err + } + var list []*entity.Case + err = g.DB(consts.DbGroupDefault).Model(consts.TableNameCase).Ctx(ctx). + Page(page, pageSize).OrderDesc("id").Scan(&list) + if list == nil { + list = make([]*entity.Case, 0) + } + return list, total, err +} + +func (d *caseDao) Insert(ctx context.Context, m *entity.Case) (int64, error) { + now := gtime.Now().Format("Y-m-d H:i:s") + r, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameCase).Ctx(ctx).Data(g.Map{ + "title": m.Title, + "case_number": m.CaseNumber, + "case_type": m.CaseType, + "court": m.Court, + "parties": m.Parties, + "description": m.Description, + "dataset_ids": m.DatasetIds, + "status": m.Status, + "created_at": now, + "updated_at": now, + }).Insert() + if err != nil { + return 0, err + } + return r.LastInsertId() +} + +func (d *caseDao) Update(ctx context.Context, id int64, data g.Map) error { + data["updated_at"] = gtime.Now().Format("Y-m-d H:i:s") + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameCase).Ctx(ctx).Data(data).Where("id", id).Update() + return err +} + +func (d *caseDao) Delete(ctx context.Context, id int64) error { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameCase).Ctx(ctx).Where("id", id).Delete() + return err +} diff --git a/kb/dao/case_evidence_dao.go b/kb/dao/case_evidence_dao.go new file mode 100644 index 0000000..80391f2 --- /dev/null +++ b/kb/dao/case_evidence_dao.go @@ -0,0 +1,74 @@ +package dao + +import ( + "context" + + "rag-local/kb/consts" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +var CaseEvidence = &caseEvidenceDao{} + +type caseEvidenceDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB(consts.DbGroupDefault).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCaseEvidence+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + case_id INTEGER NOT NULL, + evidence_id INTEGER NOT NULL, + created_at DATETIME DEFAULT (datetime('now','localtime')), + UNIQUE(case_id, evidence_id) + )`) + if err != nil { + g.Log().Warningf(ctx, "create kb_case_evidence table failed: %v", err) + } + if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_kb_case_evidence_case_id ON "+consts.TableNameCaseEvidence+"(case_id)"); err != nil { + g.Log().Warningf(ctx, "create index idx_kb_case_evidence_case_id failed: %v", err) + } + if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_kb_case_evidence_evidence_id ON "+consts.TableNameCaseEvidence+"(evidence_id)"); err != nil { + g.Log().Warningf(ctx, "create index idx_kb_case_evidence_evidence_id failed: %v", err) + } +} + +func (d *caseEvidenceDao) InsertAll(ctx context.Context, caseId int64, evidenceIds []int64) error { + if len(evidenceIds) == 0 { + return nil + } + now := gtime.Now().Format("Y-m-d H:i:s") + for _, eid := range evidenceIds { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameCaseEvidence).Ctx(ctx).Data(g.Map{ + "case_id": caseId, + "evidence_id": eid, + "created_at": now, + }).Insert() + if err != nil { + return err + } + } + return nil +} + +func (d *caseEvidenceDao) DeleteByCaseId(ctx context.Context, caseId int64) error { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameCaseEvidence).Ctx(ctx).Where("case_id", caseId).Delete() + return err +} + +func (d *caseEvidenceDao) DeleteByCaseAndEvidence(ctx context.Context, caseId, evidenceId int64) error { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameCaseEvidence).Ctx(ctx). + Where("case_id", caseId).Where("evidence_id", evidenceId).Delete() + return err +} + +func (d *caseEvidenceDao) ListByCaseId(ctx context.Context, caseId int64) ([]*entity.CaseEvidence, error) { + var list []*entity.CaseEvidence + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameCaseEvidence).Ctx(ctx). + Where("case_id", caseId).OrderDesc("id").Scan(&list) + if list == nil { + list = make([]*entity.CaseEvidence, 0) + } + return list, err +} diff --git a/kb/dao/contract_task_dao.go b/kb/dao/contract_task_dao.go index 5862b9d..c305b9b 100644 --- a/kb/dao/contract_task_dao.go +++ b/kb/dao/contract_task_dao.go @@ -36,6 +36,23 @@ func init() { if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_kb_contract_task_status ON "+consts.TableNameContractTask+"(status)"); err != nil { g.Log().Warningf(ctx, "create index idx_kb_contract_task_status failed: %v", err) } + // 迁移:添加 case_id 列(关联我的案件) + for _, col := range []struct { + name string + ddl string + }{ + {"case_id", "case_id INTEGER NOT NULL DEFAULT 0"}, + } { + cnt, err := g.DB(consts.DbGroupDefault).Ctx(ctx).GetValue(ctx, + "SELECT COUNT(*) FROM pragma_table_info('"+consts.TableNameContractTask+"') WHERE name=?", col.name) + if err != nil || cnt.Int64() > 0 { + continue + } + if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, + "ALTER TABLE "+consts.TableNameContractTask+" ADD COLUMN "+col.ddl); err != nil { + g.Log().Warningf(ctx, "migrate kb_contract_task add column %s failed: %v", col.name, err) + } + } } func (d *contractTaskDao) GetOne(ctx context.Context, id int64) (*entity.ContractTask, error) { @@ -70,12 +87,23 @@ func (d *contractTaskDao) List(ctx context.Context, page, pageSize int) ([]*enti return list, total, err } -func (d *contractTaskDao) Insert(ctx context.Context, filename, filePath, datasetIds string) (int64, error) { +func (d *contractTaskDao) ListByCaseId(ctx context.Context, caseId int64) ([]*entity.ContractTask, error) { + var list []*entity.ContractTask + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractTask).Ctx(ctx). + Where("case_id", caseId).OrderDesc("id").Scan(&list) + if list == nil { + list = make([]*entity.ContractTask, 0) + } + return list, err +} + +func (d *contractTaskDao) Insert(ctx context.Context, filename, filePath, datasetIds string, caseId int64) (int64, error) { now := gtime.Now().Format("Y-m-d H:i:s") r, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractTask).Ctx(ctx).Data(g.Map{ "filename": filename, "file_path": filePath, "dataset_ids": datasetIds, + "case_id": caseId, "status": consts.TaskStatusPending, "created_at": now, "updated_at": now, diff --git a/kb/dao/evidence_dao.go b/kb/dao/evidence_dao.go new file mode 100644 index 0000000..89dfd3b --- /dev/null +++ b/kb/dao/evidence_dao.go @@ -0,0 +1,115 @@ +package dao + +import ( + "context" + "database/sql" + "errors" + + "rag-local/kb/consts" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +var Evidence = &evidenceDao{} + +type evidenceDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB(consts.DbGroupDefault).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameEvidence+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '', + file_type TEXT NOT NULL DEFAULT '', + evidence_type TEXT NOT NULL DEFAULT 'document', + description TEXT NOT NULL DEFAULT '', + case_id INTEGER NOT NULL DEFAULT 0, + dataset_ids TEXT NOT NULL DEFAULT '', + contract_task_id INTEGER NOT NULL DEFAULT 0, + status INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT (datetime('now','localtime')), + updated_at DATETIME DEFAULT (datetime('now','localtime')) + )`) + if err != nil { + g.Log().Warningf(ctx, "create kb_evidence table failed: %v", err) + } + if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_kb_evidence_case_id ON "+consts.TableNameEvidence+"(case_id)"); err != nil { + g.Log().Warningf(ctx, "create index idx_kb_evidence_case_id failed: %v", err) + } +} + +func (d *evidenceDao) GetOne(ctx context.Context, id int64) (*entity.Evidence, error) { + var m entity.Evidence + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameEvidence).Ctx(ctx).Where("id", id).Scan(&m) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (d *evidenceDao) List(ctx context.Context, page, pageSize int) ([]*entity.Evidence, int, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + total, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameEvidence).Ctx(ctx).Count() + if err != nil { + return nil, 0, err + } + var list []*entity.Evidence + err = g.DB(consts.DbGroupDefault).Model(consts.TableNameEvidence).Ctx(ctx). + Page(page, pageSize).OrderDesc("id").Scan(&list) + if list == nil { + list = make([]*entity.Evidence, 0) + } + return list, total, err +} + +func (d *evidenceDao) Insert(ctx context.Context, m *entity.Evidence) (int64, error) { + now := gtime.Now().Format("Y-m-d H:i:s") + r, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameEvidence).Ctx(ctx).Data(g.Map{ + "filename": m.Filename, + "file_path": m.FilePath, + "file_type": m.FileType, + "evidence_type": m.EvidenceType, + "description": m.Description, + "case_id": m.CaseId, + "dataset_ids": m.DatasetIds, + "contract_task_id": m.ContractTaskId, + "status": m.Status, + "created_at": now, + "updated_at": now, + }).Insert() + if err != nil { + return 0, err + } + return r.LastInsertId() +} + +func (d *evidenceDao) Update(ctx context.Context, id int64, data g.Map) error { + data["updated_at"] = gtime.Now().Format("Y-m-d H:i:s") + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameEvidence).Ctx(ctx).Data(data).Where("id", id).Update() + return err +} + +func (d *evidenceDao) Delete(ctx context.Context, id int64) error { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameEvidence).Ctx(ctx).Where("id", id).Delete() + return err +} + +func (d *evidenceDao) ListByCaseId(ctx context.Context, caseId int64) ([]*entity.Evidence, error) { + var list []*entity.Evidence + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameEvidence).Ctx(ctx). + Where("case_id", caseId).OrderDesc("id").Scan(&list) + if list == nil { + list = make([]*entity.Evidence, 0) + } + return list, err +} diff --git a/kb/dao/reference_case_dao.go b/kb/dao/reference_case_dao.go new file mode 100644 index 0000000..bf01e49 --- /dev/null +++ b/kb/dao/reference_case_dao.go @@ -0,0 +1,102 @@ +package dao + +import ( + "context" + "database/sql" + "errors" + + "rag-local/kb/consts" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +var ReferenceCase = &referenceCaseDao{} + +type referenceCaseDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB(consts.DbGroupDefault).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameReferenceCase+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL DEFAULT '', + case_number TEXT NOT NULL DEFAULT '', + case_type TEXT NOT NULL DEFAULT '', + court TEXT NOT NULL DEFAULT '', + parties TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + dataset_id INTEGER NOT NULL DEFAULT 0, + status INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT (datetime('now','localtime')), + updated_at DATETIME DEFAULT (datetime('now','localtime')) + )`) + if err != nil { + g.Log().Warningf(ctx, "create kb_reference_case table failed: %v", err) + } +} + +func (d *referenceCaseDao) GetOne(ctx context.Context, id int64) (*entity.ReferenceCase, error) { + var m entity.ReferenceCase + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameReferenceCase).Ctx(ctx).Where("id", id).Scan(&m) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (d *referenceCaseDao) List(ctx context.Context, page, pageSize int) ([]*entity.ReferenceCase, int, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + total, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameReferenceCase).Ctx(ctx).Count() + if err != nil { + return nil, 0, err + } + var list []*entity.ReferenceCase + err = g.DB(consts.DbGroupDefault).Model(consts.TableNameReferenceCase).Ctx(ctx). + Page(page, pageSize).OrderDesc("id").Scan(&list) + if list == nil { + list = make([]*entity.ReferenceCase, 0) + } + return list, total, err +} + +func (d *referenceCaseDao) Insert(ctx context.Context, m *entity.ReferenceCase) (int64, error) { + now := gtime.Now().Format("Y-m-d H:i:s") + r, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameReferenceCase).Ctx(ctx).Data(g.Map{ + "title": m.Title, + "case_number": m.CaseNumber, + "case_type": m.CaseType, + "court": m.Court, + "parties": m.Parties, + "description": m.Description, + "dataset_id": m.DatasetId, + "status": m.Status, + "created_at": now, + "updated_at": now, + }).Insert() + if err != nil { + return 0, err + } + return r.LastInsertId() +} + +func (d *referenceCaseDao) Update(ctx context.Context, id int64, data g.Map) error { + data["updated_at"] = gtime.Now().Format("Y-m-d H:i:s") + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameReferenceCase).Ctx(ctx). + Data(data).Where("id", id).Update() + return err +} + +func (d *referenceCaseDao) Delete(ctx context.Context, id int64) error { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameReferenceCase).Ctx(ctx). + Where("id", id).Delete() + return err +} diff --git a/kb/model/dto/case_dto.go b/kb/model/dto/case_dto.go new file mode 100644 index 0000000..119acc4 --- /dev/null +++ b/kb/model/dto/case_dto.go @@ -0,0 +1,71 @@ +package dto + +import ( + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type SaveCaseReq struct { + g.Meta `path:"/save" method:"post" tags:"案例管理" summary:"创建/更新案例"` + Id int64 `json:"id"` + Title string `v:"required" json:"title"` + CaseNumber string `json:"case_number"` + CaseType string `json:"case_type"` + Court string `json:"court"` + Parties string `json:"parties"` + Description string `json:"description"` + DatasetIds string `json:"dataset_ids"` + Status int `json:"status"` +} + +type SaveCaseRes struct { + Id int64 `json:"id"` +} + +type ListCaseReq struct { + g.Meta `path:"/list" method:"get" tags:"案例管理" summary:"案例列表"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type ListCaseRes struct { + List []*entity.Case `json:"list"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type GetCaseDetailReq struct { + g.Meta `path:"/detail" method:"get" tags:"案例管理" summary:"案例详情"` + Id int64 `v:"required" json:"id"` +} + +type GetCaseDetailRes struct { + Case *entity.Case `json:"case"` + Evidences []*entity.Evidence `json:"evidences"` + Contracts []*entity.ContractTask `json:"contracts"` +} + +type DeleteCaseReq struct { + g.Meta `path:"/delete" method:"post" tags:"案例管理" summary:"删除案例"` + Id int64 `v:"required" json:"id"` +} + +type DeleteCaseRes struct{} + +type LinkEvidenceReq struct { + g.Meta `path:"/link_evidence" method:"post" tags:"案例管理" summary:"关联证物到案例"` + CaseId int64 `v:"required" json:"case_id"` + EvidenceIds []int64 `v:"required" json:"evidence_ids"` +} + +type LinkEvidenceRes struct{} + +type UnlinkEvidenceReq struct { + g.Meta `path:"/unlink_evidence" method:"post" tags:"案例管理" summary:"取消关联证物"` + CaseId int64 `v:"required" json:"case_id"` + EvidenceId int64 `v:"required" json:"evidence_id"` +} + +type UnlinkEvidenceRes struct{} diff --git a/kb/model/dto/contract_dto.go b/kb/model/dto/contract_dto.go index 2411e75..ba1d550 100644 --- a/kb/model/dto/contract_dto.go +++ b/kb/model/dto/contract_dto.go @@ -12,6 +12,7 @@ type UploadContractReq struct { g.Meta `path:"/upload" method:"post" tags:"合同标注" summary:"上传合同并启动标注"` DatasetIds string `json:"dataset_ids" dc:"法律语料数据集ID JSON 数组,如 [1,3]"` File *ghttp.UploadFile `json:"file" dc:"合同文件"` + CaseId int64 `json:"case_id" dc:"关联案例ID(0=不关联)"` } type UploadContractRes struct { diff --git a/kb/model/dto/evidence_dto.go b/kb/model/dto/evidence_dto.go new file mode 100644 index 0000000..efdb8da --- /dev/null +++ b/kb/model/dto/evidence_dto.go @@ -0,0 +1,59 @@ +package dto + +import ( + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" +) + +type UploadEvidenceReq struct { + g.Meta `path:"/upload" method:"post" tags:"证物收集" summary:"上传证物"` + EvidenceType string `json:"evidence_type" dc:"证据类型:document/image/audio/video"` + Description string `json:"description" dc:"证据描述"` + DatasetIds string `json:"dataset_ids" dc:"关联法律语料数据集ID JSON 数组"` + File *ghttp.UploadFile `json:"file" dc:"证物文件"` + CaseId int64 `json:"case_id" dc:"关联案例ID(0=不关联)"` +} + +type UploadEvidenceRes struct { + Id int64 `json:"id"` +} + +type ListEvidenceReq struct { + g.Meta `path:"/list" method:"get" tags:"证物收集" summary:"证物列表"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type ListEvidenceRes struct { + List []*entity.Evidence `json:"list"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type GetEvidenceDetailReq struct { + g.Meta `path:"/detail" method:"get" tags:"证物收集" summary:"证物详情"` + Id int64 `v:"required" json:"id"` +} + +type GetEvidenceDetailRes struct { + Evidence *entity.Evidence `json:"evidence"` +} + +type DeleteEvidenceReq struct { + g.Meta `path:"/delete" method:"post" tags:"证物收集" summary:"删除证物"` + Id int64 `v:"required" json:"id"` +} + +type DeleteEvidenceRes struct{} + +type UpdateEvidenceReq struct { + g.Meta `path:"/update" method:"post" tags:"证物收集" summary:"更新证物描述"` + Id int64 `v:"required" json:"id"` + Description string `json:"description"` + DatasetIds string `json:"dataset_ids"` +} + +type UpdateEvidenceRes struct{} diff --git a/kb/model/dto/reference_case_dto.go b/kb/model/dto/reference_case_dto.go new file mode 100644 index 0000000..989828c --- /dev/null +++ b/kb/model/dto/reference_case_dto.go @@ -0,0 +1,65 @@ +package dto + +import ( + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" +) + +type SaveReferenceCaseReq struct { + g.Meta `path:"/save" method:"post" tags:"参考案例库" summary:"创建/更新参考案例"` + Id int64 `json:"id"` + Title string `v:"required" json:"title"` + CaseNumber string `json:"case_number"` + CaseType string `json:"case_type"` + Court string `json:"court"` + Parties string `json:"parties"` + Description string `json:"description"` +} + +type SaveReferenceCaseRes struct { + Id int64 `json:"id"` +} + +type ListReferenceCaseReq struct { + g.Meta `path:"/list" method:"get" tags:"参考案例库" summary:"参考案例列表"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type ListReferenceCaseRes struct { + List []*entity.ReferenceCase `json:"list"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type GetReferenceCaseDetailReq struct { + g.Meta `path:"/detail" method:"get" tags:"参考案例库" summary:"参考案例详情"` + Id int64 `v:"required" json:"id"` +} + +type ReferenceCaseDoc struct { + *entity.Document +} + +type GetReferenceCaseDetailRes struct { + ReferenceCase *entity.ReferenceCase `json:"reference_case"` + Documents []*entity.Document `json:"documents"` +} + +type DeleteReferenceCaseReq struct { + g.Meta `path:"/delete" method:"post" tags:"参考案例库" summary:"删除参考案例"` + Id int64 `v:"required" json:"id"` +} + +type DeleteReferenceCaseRes struct{} + +type UploadReferenceCaseDocReq struct { + g.Meta `path:"/upload_doc" method:"post" tags:"参考案例库" summary:"上传文件到参考案例"` + Id int64 `v:"required" json:"id" dc:"参考案例ID"` + File *ghttp.UploadFile `json:"file" dc:"案件文件"` +} + +type UploadReferenceCaseDocRes struct{} diff --git a/kb/model/entity/case.go b/kb/model/entity/case.go new file mode 100644 index 0000000..e4fd423 --- /dev/null +++ b/kb/model/entity/case.go @@ -0,0 +1,17 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type Case struct { + Id int64 `orm:"id" json:"id"` + Title string `orm:"title" json:"title"` + CaseNumber string `orm:"case_number" json:"case_number"` + CaseType string `orm:"case_type" json:"case_type"` + Court string `orm:"court" json:"court"` + Parties string `orm:"parties" json:"parties"` + Description string `orm:"description" json:"description"` + DatasetIds string `orm:"dataset_ids" json:"dataset_ids"` + Status int `orm:"status" json:"status"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` +} diff --git a/kb/model/entity/case_evidence.go b/kb/model/entity/case_evidence.go new file mode 100644 index 0000000..33d7b52 --- /dev/null +++ b/kb/model/entity/case_evidence.go @@ -0,0 +1,10 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type CaseEvidence struct { + Id int64 `orm:"id" json:"id"` + CaseId int64 `orm:"case_id" json:"case_id"` + EvidenceId int64 `orm:"evidence_id" json:"evidence_id"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/kb/model/entity/contract_task.go b/kb/model/entity/contract_task.go index 7dce254..a37b5ce 100644 --- a/kb/model/entity/contract_task.go +++ b/kb/model/entity/contract_task.go @@ -7,6 +7,7 @@ type ContractTask struct { Filename string `orm:"filename" json:"filename"` FilePath string `orm:"file_path" json:"file_path"` DatasetIds string `orm:"dataset_ids" json:"dataset_ids"` + CaseId int64 `orm:"case_id" json:"case_id"` Status int `orm:"status" json:"status"` TotalClauses int `orm:"total_clauses" json:"total_clauses"` DoneClauses int `orm:"done_clauses" json:"done_clauses"` diff --git a/kb/model/entity/evidence.go b/kb/model/entity/evidence.go new file mode 100644 index 0000000..a9db63e --- /dev/null +++ b/kb/model/entity/evidence.go @@ -0,0 +1,18 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type Evidence struct { + Id int64 `orm:"id" json:"id"` + Filename string `orm:"filename" json:"filename"` + FilePath string `orm:"file_path" json:"file_path"` + FileType string `orm:"file_type" json:"file_type"` + EvidenceType string `orm:"evidence_type" json:"evidence_type"` + Description string `orm:"description" json:"description"` + CaseId int64 `orm:"case_id" json:"case_id"` + DatasetIds string `orm:"dataset_ids" json:"dataset_ids"` + ContractTaskId int64 `orm:"contract_task_id" json:"contract_task_id"` + Status int `orm:"status" json:"status"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` +} diff --git a/kb/model/entity/reference_case.go b/kb/model/entity/reference_case.go new file mode 100644 index 0000000..455948c --- /dev/null +++ b/kb/model/entity/reference_case.go @@ -0,0 +1,17 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type ReferenceCase struct { + Id int64 `orm:"id" json:"id"` + Title string `orm:"title" json:"title"` + CaseNumber string `orm:"case_number" json:"case_number"` + CaseType string `orm:"case_type" json:"case_type"` + Court string `orm:"court" json:"court"` + Parties string `orm:"parties" json:"parties"` + Description string `orm:"description" json:"description"` + DatasetId int64 `orm:"dataset_id" json:"dataset_id"` + Status int `orm:"status" json:"status"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` +} diff --git a/kb/service/annotation_service.go b/kb/service/annotation_service.go index 3e31f6c..791fce0 100644 --- a/kb/service/annotation_service.go +++ b/kb/service/annotation_service.go @@ -275,6 +275,8 @@ func (s *annotationService) processOne(ctx context.Context) { if out.err != nil { failed++ _ = dao.ContractClause.UpdateStatus(ctx, out.clauseId, consts.TaskStatusFailed, out.err.Error()) + // 失败也计入进度(已处理条款数),避免任务收尾时进度停在 <100% + tickProgress() continue } if out.noCands { @@ -298,6 +300,7 @@ func (s *annotationService) processOne(ctx context.Context) { if err := dao.ContractRisk.InsertAll(ctx, out.risks); err != nil { failed++ _ = dao.ContractClause.UpdateStatus(ctx, out.clauseId, consts.TaskStatusFailed, err.Error()) + tickProgress() continue } } @@ -311,11 +314,14 @@ func (s *annotationService) processOne(ctx context.Context) { } } + // 任一条款失败 → 任务置失败(此前无条件置 Done,出现「任务完成 + 进度 75% + 2 条失败」不一致) + status := consts.TaskStatusDone msg := "" if failed > 0 { + status = consts.TaskStatusFailed msg = fmt.Sprintf("%d 条条款标注失败", failed) } - if err := dao.ContractTask.UpdateStatus(ctx, task.Id, consts.TaskStatusDone, msg); err != nil { + if err := dao.ContractTask.UpdateStatus(ctx, task.Id, status, msg); err != nil { g.Log().Errorf(ctx, "mark annotation task done failed: %v", err) } } @@ -436,6 +442,42 @@ func (s *annotationService) mergeHit(merged map[int64]*annoCandidate, chunkId, d c.RrfScore += 1 / (float64(consts.RrfK) + rank + 1) } +// riskItem LLM 判定的单条风险点(JSON 解析中间态) +type riskItem struct { + Level string `json:"level"` + Desc string `json:"desc"` + Laws []struct { + Cand int `json:"cand"` + LawItem string `json:"law_item"` + } `json:"laws"` +} + +// parseJudgeRisks 容错解析判定输出:对象 {"risks":[...]} 优先,失败回退裸数组 [...](视为 risks 列表)。 +// 剥 Markdown 代码围栏后按首个 {..} / [..] 区间解析;Qwen3.5 实测偶发输出裸 [] 或带杂字(如 `[]"`), +// 不按约定包装对象即解析失败,裸数组一律按「无风险」或数组内容处理。 +func parseJudgeRisks(content string) ([]riskItem, error) { + content = strings.TrimSpace(content) + content = strings.TrimPrefix(content, "```json") + content = strings.TrimPrefix(content, "```") + content = strings.TrimSuffix(content, "```") + content = strings.TrimSpace(content) + if i, j := strings.Index(content, "{"), strings.LastIndex(content, "}"); i >= 0 && j > i { + var obj struct { + Risks []riskItem `json:"risks"` + } + if err := json.Unmarshal([]byte(content[i:j+1]), &obj); err == nil { + return obj.Risks, nil + } + } + if i, j := strings.Index(content, "["), strings.LastIndex(content, "]"); i >= 0 && j > i { + var arr []riskItem + if err := json.Unmarshal([]byte(content[i:j+1]), &arr); err == nil { + return arr, nil + } + } + return nil, gerror.New("无法从模型输出中解析 JSON: " + content) +} + // judgeRisks LLM 风险判定:一次非流式调用识别该条款的风险点(等级+描述+支撑法条),无风险返回空数组 func (s *annotationService) judgeRisks(ctx context.Context, model *OpenAIChatModel, clause *entity.ContractClause, cands []annoCandidate) ([]*entity.ContractRisk, error) { var sb strings.Builder @@ -468,21 +510,8 @@ func (s *annotationService) judgeRisks(ctx context.Context, model *OpenAIChatMod if err != nil { return nil, err } - content := msg.Content - if i, j := strings.Index(content, "{"), strings.LastIndex(content, "}"); i >= 0 && j > i { - content = content[i : j+1] - } - var resp struct { - Risks []struct { - Level string `json:"level"` - Desc string `json:"desc"` - Laws []struct { - Cand int `json:"cand"` - LawItem string `json:"law_item"` - } `json:"laws"` - } `json:"risks"` - } - if err := json.Unmarshal([]byte(content), &resp); err != nil { + items, err := parseJudgeRisks(msg.Content) + if err != nil { return nil, gerror.Wrap(err, "解析风险判定结果失败: "+msg.Content) } // 法条来源溯源(展示增强,失败仅告警不阻断判定):候选 chunk → 文档名,单表两条 SQL + 内存组装 @@ -522,8 +551,8 @@ func (s *annotationService) judgeRisks(ctx context.Context, model *OpenAIChatMod } } } - risks := make([]*entity.ContractRisk, 0, len(resp.Risks)) - for _, r := range resp.Risks { + risks := make([]*entity.ContractRisk, 0, len(items)) + for _, r := range items { desc := strings.TrimSpace(r.Desc) if desc == "" { continue @@ -884,15 +913,22 @@ func (s *annotationService) fail(ctx context.Context, task *entity.ContractTask, } // Upload 保存合同文件到 workspace/contract/{yyyymmdd}/{token}.ext 并创建标注任务 -func (s *annotationService) Upload(ctx context.Context, file *ghttp.UploadFile, datasetIdsJSON string) (int64, error) { +func (s *annotationService) Upload(ctx context.Context, file *ghttp.UploadFile, datasetIdsJSON string, caseId int64) (int64, error) { var dsIds []int64 if datasetIdsJSON != "" { if err := json.Unmarshal([]byte(datasetIdsJSON), &dsIds); err != nil { return 0, gerror.New("dataset_ids 格式错误,应为 JSON 数组") } } + // 未指定数据集时自动使用所有已有数据集 if len(dsIds) == 0 { - return 0, gerror.New("请至少选择一个法律语料数据集") + allDs, _ := dao.Dataset.List(ctx) + for _, d := range allDs { + dsIds = append(dsIds, d.Id) + } + } + if len(dsIds) == 0 { + return 0, gerror.New("系统中暂无数据集,请先在数据集模块创建") } f, err := file.Open() if err != nil { @@ -923,7 +959,7 @@ func (s *annotationService) Upload(ctx context.Context, file *ghttp.UploadFile, for _, id := range dsIds { ids = append(ids, strconv.FormatInt(id, 10)) } - id, err := dao.ContractTask.Insert(ctx, file.Filename, relPath, strings.Join(ids, ",")) + id, err := dao.ContractTask.Insert(ctx, file.Filename, relPath, strings.Join(ids, ","), caseId) if err != nil { _ = os.Remove(absPath) return 0, err diff --git a/kb/service/case_service.go b/kb/service/case_service.go new file mode 100644 index 0000000..bbb0fcf --- /dev/null +++ b/kb/service/case_service.go @@ -0,0 +1,117 @@ +package service + +import ( + "context" + + "rag-local/kb/dao" + "rag-local/kb/model/dto" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +var CaseService = &caseService{} + +type caseService struct{} + +func (s *caseService) Save(ctx context.Context, req *dto.SaveCaseReq) (int64, error) { + m := &entity.Case{ + Id: req.Id, + Title: req.Title, + CaseNumber: req.CaseNumber, + CaseType: req.CaseType, + Court: req.Court, + Parties: req.Parties, + Description: req.Description, + DatasetIds: req.DatasetIds, + Status: req.Status, + } + + if m.Status == 0 { + m.Status = 0 // 待处理 + } + + if req.Id == 0 { + // 新增 + return dao.Case.Insert(ctx, m) + } + // 更新 + data := g.Map{ + "title": m.Title, + "case_number": m.CaseNumber, + "case_type": m.CaseType, + "court": m.Court, + "parties": m.Parties, + "description": m.Description, + "dataset_ids": m.DatasetIds, + "status": m.Status, + } + if err := dao.Case.Update(ctx, req.Id, data); err != nil { + return 0, err + } + return req.Id, nil +} + +func (s *caseService) List(ctx context.Context, page, pageSize int) ([]*entity.Case, int, error) { + return dao.Case.List(ctx, page, pageSize) +} + +func (s *caseService) Detail(ctx context.Context, id int64) (*entity.Case, []*entity.Evidence, []*entity.ContractTask, error) { + c, err := dao.Case.GetOne(ctx, id) + if err != nil { + return nil, nil, nil, err + } + if c == nil { + return nil, nil, nil, gerror.New("案例不存在") + } + evidences, err := dao.Evidence.ListByCaseId(ctx, id) + if err != nil { + return c, nil, nil, err + } + contracts, err := dao.ContractTask.ListByCaseId(ctx, id) + if err != nil { + return c, evidences, nil, err + } + return c, evidences, contracts, nil +} + +func (s *caseService) Delete(ctx context.Context, id int64) error { + // 清除关联记录(不删除证物本身) + if err := dao.CaseEvidence.DeleteByCaseId(ctx, id); err != nil { + return err + } + // 清除证物的 case_id 关联 + evidences, _ := dao.Evidence.ListByCaseId(ctx, id) + for _, ev := range evidences { + _ = dao.Evidence.Update(ctx, ev.Id, g.Map{"case_id": 0}) + } + return dao.Case.Delete(ctx, id) +} + +func (s *caseService) LinkEvidence(ctx context.Context, caseId int64, evidenceIds []int64) error { + c, err := dao.Case.GetOne(ctx, caseId) + if err != nil { + return err + } + if c == nil { + return gerror.New("案例不存在") + } + // 建立关联 + if err := dao.CaseEvidence.InsertAll(ctx, caseId, evidenceIds); err != nil { + return err + } + // 更新证物的 case_id + for _, eid := range evidenceIds { + _ = dao.Evidence.Update(ctx, eid, g.Map{"case_id": caseId}) + } + return nil +} + +func (s *caseService) UnlinkEvidence(ctx context.Context, caseId, evidenceId int64) error { + if err := dao.CaseEvidence.DeleteByCaseAndEvidence(ctx, caseId, evidenceId); err != nil { + return err + } + _ = dao.Evidence.Update(ctx, evidenceId, g.Map{"case_id": 0}) + return nil +} diff --git a/kb/service/chat_service.go b/kb/service/chat_service.go index 8e1d12e..b5c0055 100644 --- a/kb/service/chat_service.go +++ b/kb/service/chat_service.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "math" + "net" "net/http" "regexp" "sort" @@ -1211,28 +1212,65 @@ func doOpenAIRequest(ctx context.Context, cfg *entity.ModelConfig, url string, p if err != nil { return nil, err } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - if cfg.ApiKey != "" { - req.Header.Set("Authorization", "Bearer "+cfg.ApiKey) - } // 本地模型生成慢且多请求排队时响应可超分钟级,超时取 config.yml chat.timeout(秒) timeout := g.Cfg().MustGet(ctx, "chat.timeout", consts.DefaultLlmHttpTimeout).Int() - resp, err := (&http.Client{Timeout: time.Duration(timeout) * time.Second}).Do(req) - if err != nil { - g.Log().Errorf(ctx, "model call failed: model=%s url=%s err=%v", cfg.ModelName, url, err) - return nil, err + maxRetries := g.Cfg().MustGet(ctx, "chat.max_retries", 0).Int() + client := &http.Client{Timeout: time.Duration(timeout) * time.Second} + var lastErr error + for attempt := 0; attempt <= maxRetries; attempt++ { + if attempt > 0 { + g.Log().Warningf(ctx, "model call retry %d/%d: model=%s url=%s err=%v", attempt, maxRetries, cfg.ModelName, url, lastErr) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(1<<(attempt-1)) * time.Second): + } + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if cfg.ApiKey != "" { + req.Header.Set("Authorization", "Bearer "+cfg.ApiKey) + } + resp, err := client.Do(req) + if err != nil { + lastErr = err + if attempt < maxRetries && isRetryableLLMErr(err) { + continue + } + g.Log().Errorf(ctx, "model call failed: model=%s url=%s err=%v", cfg.ModelName, url, err) + return nil, err + } + if resp.StatusCode >= 500 && attempt < maxRetries { + msg, _ := io.ReadAll(resp.Body) + resp.Body.Close() + lastErr = gerror.Newf("模型接口 %s 返回 %d: %s", url, resp.StatusCode, strings.TrimSpace(string(msg))) + continue + } + if resp.StatusCode >= 400 { + msg, _ := io.ReadAll(resp.Body) + resp.Body.Close() + err := gerror.Newf("模型接口 %s 返回 %d: %s", url, resp.StatusCode, strings.TrimSpace(string(msg))) + g.Log().Errorf(ctx, "model call failed: model=%s url=%s status=%d err=%v", cfg.ModelName, url, resp.StatusCode, err) + return nil, err + } + g.Log().Infof(ctx, "model call ok: model=%s url=%s status=%d dur=%s", cfg.ModelName, url, resp.StatusCode, time.Since(start).Round(time.Millisecond)) + return resp.Body, nil } - if resp.StatusCode >= 400 { - msg, _ := io.ReadAll(resp.Body) - resp.Body.Close() - err := gerror.Newf("模型接口 %s 返回 %d: %s", url, resp.StatusCode, strings.TrimSpace(string(msg))) - g.Log().Errorf(ctx, "model call failed: model=%s url=%s status=%d err=%v", cfg.ModelName, url, resp.StatusCode, err) - return nil, err + return nil, lastErr +} + +// isRetryableLLMErr 是否值得重试:超时/取消类不重试(源于排队或慢响应,重试只会重新排队); +// 连接类瞬时错误(拒绝/重置/EOF 等)与 5xx 服务端错误可重试 +func isRetryableLLMErr(err error) bool { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return false } - g.Log().Infof(ctx, "model call ok: model=%s url=%s status=%d dur=%s", cfg.ModelName, url, resp.StatusCode, time.Since(start).Round(time.Millisecond)) - return resp.Body, nil + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + return false + } + return true } diff --git a/kb/service/dataset_service.go b/kb/service/dataset_service.go index 31aa35c..cfd824e 100644 --- a/kb/service/dataset_service.go +++ b/kb/service/dataset_service.go @@ -5,6 +5,7 @@ import ( "rag-local/kb/consts" "rag-local/kb/dao" + "rag-local/kb/model/dto" "rag-local/kb/model/entity" "github.com/gogf/gf/v2/errors/gerror" @@ -19,7 +20,22 @@ func (s *datasetService) List(ctx context.Context) ([]*entity.Dataset, error) { return dao.Dataset.List(ctx) } -func (s *datasetService) Save(ctx context.Context, m *entity.Dataset) (int64, error) { +func (s *datasetService) Save(ctx context.Context, req *dto.SaveDatasetReq) (int64, error) { + // 在 service 内部创建 entity,组装 dto 到 entity 的映射 + m := &entity.Dataset{ + Id: req.Id, + Name: req.Name, + Description: req.Description, + EmbeddingCfgId: req.EmbeddingCfgId, + ChunkSize: req.ChunkSize, + ChunkOverlap: req.ChunkOverlap, + ReactRounds: req.ReactRounds, + VecTopK: req.VecTopK, + FtsTopK: req.FtsTopK, + RerankTopK: req.RerankTopK, + RecallTopK: req.RecallTopK, + } + if m.Status == 0 { m.Status = 1 } diff --git a/kb/service/evidence_service.go b/kb/service/evidence_service.go new file mode 100644 index 0000000..c903d6e --- /dev/null +++ b/kb/service/evidence_service.go @@ -0,0 +1,161 @@ +package service + +import ( + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "time" + + "rag-local/common" + "rag-local/kb/dao" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/util/gconv" +) + +var EvidenceService = &evidenceService{} + +type evidenceService struct{} + +// 合同类文件扩展名(上传时自动触发合同审计) +var contractExts = map[string]bool{ + "pdf": true, "docx": true, "doc": true, + "txt": true, "md": true, "html": true, "htm": true, +} + +// 图片类扩展名 +var imageExts = map[string]bool{ + "jpg": true, "jpeg": true, "png": true, "gif": true, "bmp": true, "webp": true, +} + +// 音视频类扩展名 +var mediaExts = map[string]bool{ + "mp3": true, "wav": true, "flac": true, "aac": true, "ogg": true, + "mp4": true, "avi": true, "mov": true, "mkv": true, "wmv": true, "flv": true, +} + +func (s *evidenceService) Upload(ctx context.Context, file *ghttp.UploadFile, evidenceType, description, datasetIdsJSON string, caseId int64) (int64, error) { + if file == nil { + return 0, gerror.New("请选择文件") + } + f, err := file.Open() + if err != nil { + return 0, err + } + defer func() { _ = f.Close() }() + data, err := io.ReadAll(f) + if err != nil { + return 0, err + } + if len(data) == 0 { + return 0, gerror.New("文件内容为空") + } + + ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Filename)), ".") + if ext == "" { + return 0, gerror.New("无法识别文件类型") + } + + // 自动推断证据类型 + if evidenceType == "" { + if imageExts[ext] { + evidenceType = "image" + } else if mediaExts[ext] { + evidenceType = "video" + } else { + evidenceType = "document" + } + } + + // 保存文件 + relDir := filepath.Join("evidence", time.Now().Format("20060102")) + relPath := filepath.Join(relDir, common.RandomToken(16)+"."+ext) + absPath := filepath.Join("workspace", relPath) + if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { + return 0, err + } + if err := os.WriteFile(absPath, data, 0o644); err != nil { + return 0, err + } + + // 插入证物记录 + evidence := &entity.Evidence{ + Filename: file.Filename, + FilePath: relPath, + FileType: ext, + EvidenceType: evidenceType, + Description: description, + DatasetIds: datasetIdsJSON, + CaseId: caseId, + Status: 0, + } + id, err := dao.Evidence.Insert(ctx, evidence) + if err != nil { + _ = os.Remove(absPath) + return 0, err + } + + // 文档类证物如果是合同类文件,自动创建合同审计任务 + if evidenceType == "document" && contractExts[ext] && datasetIdsJSON != "" { + var dsIds []int64 + if err := json.Unmarshal([]byte(datasetIdsJSON), &dsIds); err == nil && len(dsIds) > 0 { + ids := make([]string, 0, len(dsIds)) + for _, did := range dsIds { + ids = append(ids, strings.TrimSpace(gconv.String(did))) + } + contractTaskId, err := dao.ContractTask.Insert(ctx, file.Filename, relPath, strings.Join(ids, ","), 0) + if err == nil && contractTaskId > 0 { + _ = dao.Evidence.Update(ctx, id, g.Map{"contract_task_id": contractTaskId}) + evidence.ContractTaskId = contractTaskId + } + } + } + + return id, nil +} + +func (s *evidenceService) List(ctx context.Context, page, pageSize int) ([]*entity.Evidence, int, error) { + return dao.Evidence.List(ctx, page, pageSize) +} + +func (s *evidenceService) Detail(ctx context.Context, id int64) (*entity.Evidence, error) { + return dao.Evidence.GetOne(ctx, id) +} + +func (s *evidenceService) Delete(ctx context.Context, id int64) error { + ev, err := dao.Evidence.GetOne(ctx, id) + if err != nil { + return err + } + if ev == nil { + return gerror.New("证物不存在") + } + // 删除文件 + absPath := filepath.Join("workspace", ev.FilePath) + _ = os.Remove(absPath) + // 删除关联记录 + if ev.CaseId > 0 { + _ = dao.CaseEvidence.DeleteByCaseAndEvidence(ctx, ev.CaseId, id) + } + return dao.Evidence.Delete(ctx, id) +} + +func (s *evidenceService) Update(ctx context.Context, id int64, description, datasetIds string) error { + data := g.Map{} + if description != "" { + data["description"] = description + } + if datasetIds != "" { + data["dataset_ids"] = datasetIds + } + if len(data) == 0 { + return nil + } + return dao.Evidence.Update(ctx, id, data) +} diff --git a/kb/service/reference_case_service.go b/kb/service/reference_case_service.go new file mode 100644 index 0000000..8238157 --- /dev/null +++ b/kb/service/reference_case_service.go @@ -0,0 +1,101 @@ +package service + +import ( + "context" + + "rag-local/kb/dao" + "rag-local/kb/model/dto" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" +) + +var ReferenceCaseService = &referenceCaseService{} + +type referenceCaseService struct{} + +func (s *referenceCaseService) Save(ctx context.Context, req *dto.SaveReferenceCaseReq) (int64, error) { + if req.Id == 0 { + // 新建:自动创建关联数据集 + dsId, err := dao.Dataset.Insert(ctx, &entity.Dataset{ + Name: req.Title, + Description: "参考案例库 - " + req.Title, + Status: 1, + }) + if err != nil { + return 0, err + } + rc := &entity.ReferenceCase{ + Title: req.Title, + CaseNumber: req.CaseNumber, + CaseType: req.CaseType, + Court: req.Court, + Parties: req.Parties, + Description: req.Description, + DatasetId: dsId, + Status: 0, + } + return dao.ReferenceCase.Insert(ctx, rc) + } + // 更新 + data := g.Map{ + "title": req.Title, + "case_number": req.CaseNumber, + "case_type": req.CaseType, + "court": req.Court, + "parties": req.Parties, + "description": req.Description, + } + if err := dao.ReferenceCase.Update(ctx, req.Id, data); err != nil { + return 0, err + } + return req.Id, nil +} + +func (s *referenceCaseService) List(ctx context.Context, page, pageSize int) ([]*entity.ReferenceCase, int, error) { + return dao.ReferenceCase.List(ctx, page, pageSize) +} + +func (s *referenceCaseService) Detail(ctx context.Context, id int64) (*entity.ReferenceCase, []*entity.Document, error) { + rc, err := dao.ReferenceCase.GetOne(ctx, id) + if err != nil { + return nil, nil, err + } + if rc == nil { + return nil, nil, gerror.New("参考案例不存在") + } + docs, _, err := DocumentService.List(ctx, rc.DatasetId, 1, 1000) + if err != nil { + return rc, nil, err + } + return rc, docs, nil +} + +func (s *referenceCaseService) Delete(ctx context.Context, id int64) error { + rc, err := dao.ReferenceCase.GetOne(ctx, id) + if err != nil { + return err + } + if rc == nil { + return gerror.New("参考案例不存在") + } + // 删除关联数据集及其文档 + if rc.DatasetId > 0 { + _ = DatasetService.Delete(ctx, rc.DatasetId) + } + return dao.ReferenceCase.Delete(ctx, id) +} + +func (s *referenceCaseService) UploadDocument(ctx context.Context, id int64, file *ghttp.UploadFile) error { + rc, err := dao.ReferenceCase.GetOne(ctx, id) + if err != nil { + return err + } + if rc == nil { + return gerror.New("参考案例不存在") + } + _, err = DocumentService.Upload(ctx, rc.DatasetId, file) + return err +} diff --git a/main.go b/main.go index c594554..ea7548c 100644 --- a/main.go +++ b/main.go @@ -34,6 +34,12 @@ func main() { controller.KgEntity, controller.KgRelation, controller.Contract, + controller.Evidence, + controller.ReferenceCase, + }) + // case 是 Go 关键字,结构体名无法直接映射为 /case 路由,手动注册 + commonHttp.Httpserver.Group("/case", func(group *ghttp.RouterGroup) { + group.Bind(controller.Case) }) // ==================== Workspace 文件服务(源文件访问,路径穿越防护) ==================== diff --git a/ui-src/src/api/case.js b/ui-src/src/api/case.js new file mode 100644 index 0000000..5d9ddf6 --- /dev/null +++ b/ui-src/src/api/case.js @@ -0,0 +1,25 @@ +import request from './request.js' + +export function saveCase(data) { + return request.post('/case/save', data) +} + +export function listCases(params) { + return request.get('/case/list', { params }) +} + +export function getCaseDetail(id) { + return request.get('/case/detail', { params: { id } }) +} + +export function deleteCase(id) { + return request.post('/case/delete', { id }) +} + +export function linkEvidence(data) { + return request.post('/case/link_evidence', data) +} + +export function unlinkEvidence(data) { + return request.post('/case/unlink_evidence', data) +} diff --git a/ui-src/src/api/evidence.js b/ui-src/src/api/evidence.js new file mode 100644 index 0000000..2037c7c --- /dev/null +++ b/ui-src/src/api/evidence.js @@ -0,0 +1,21 @@ +import request from './request.js' + +export function uploadEvidence(formData) { + return request.post('/evidence/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) +} + +export function listEvidence(params) { + return request.get('/evidence/list', { params }) +} + +export function getEvidenceDetail(id) { + return request.get('/evidence/detail', { params: { id } }) +} + +export function deleteEvidence(id) { + return request.post('/evidence/delete', { id }) +} + +export function updateEvidence(data) { + return request.post('/evidence/update', data) +} diff --git a/ui-src/src/api/reference_case.js b/ui-src/src/api/reference_case.js new file mode 100644 index 0000000..6cff7fd --- /dev/null +++ b/ui-src/src/api/reference_case.js @@ -0,0 +1,21 @@ +import request from './request.js' + +export function saveReferenceCase(data) { + return request.post('/reference-case/save', data) +} + +export function listReferenceCases(params) { + return request.get('/reference-case/list', { params }) +} + +export function getReferenceCaseDetail(id) { + return request.get('/reference-case/detail', { params: { id } }) +} + +export function deleteReferenceCase(id) { + return request.post('/reference-case/delete', { id }) +} + +export function uploadReferenceCaseDoc(formData) { + return request.post('/reference-case/upload_doc', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) +} diff --git a/ui-src/src/main.js b/ui-src/src/main.js index 0c96eb4..85b3a26 100644 --- a/ui-src/src/main.js +++ b/ui-src/src/main.js @@ -6,6 +6,21 @@ import zhCn from 'element-plus/dist/locale/zh-cn.mjs' import App from './App.vue' import router from './router' +// 全局样式重置:消除浏览器默认 margin/padding,防止滚动条 +const globalStyle = document.createElement('style') +globalStyle.textContent = ` + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + html, body, #app { + height: 100%; + overflow: hidden; + } +` +document.head.appendChild(globalStyle) + const app = createApp(App) app.use(createPinia()) app.use(ElementPlus, { locale: zhCn }) diff --git a/ui-src/src/router/index.js b/ui-src/src/router/index.js index 71cbc49..8d52da2 100644 --- a/ui-src/src/router/index.js +++ b/ui-src/src/router/index.js @@ -37,10 +37,22 @@ const routes = [ component: () => import('../views/KgGraph.vue') }, { - path: 'contract', - name: 'Contract', - meta: { title: '合同标注' }, - component: () => import('../views/Contract.vue') + path: 'reference-cases', + name: 'ReferenceCase', + meta: { title: '参考案例库' }, + component: () => import('../views/ReferenceCase.vue') + }, + { + path: 'cases', + name: 'CaseList', + meta: { title: '我的案件' }, + component: () => import('../views/CaseList.vue') + }, + { + path: 'cases/:id', + name: 'CaseDetail', + meta: { title: '案件详情' }, + component: () => import('../views/CaseDetail.vue') }, { path: 'settings', diff --git a/ui-src/src/views/CaseDetail.vue b/ui-src/src/views/CaseDetail.vue new file mode 100644 index 0000000..e449259 --- /dev/null +++ b/ui-src/src/views/CaseDetail.vue @@ -0,0 +1,541 @@ + + + + + diff --git a/ui-src/src/views/CaseList.vue b/ui-src/src/views/CaseList.vue new file mode 100644 index 0000000..7bd9ae7 --- /dev/null +++ b/ui-src/src/views/CaseList.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/ui-src/src/views/Contract.vue b/ui-src/src/views/Contract.vue index 8f6e144..aaa61da 100644 --- a/ui-src/src/views/Contract.vue +++ b/ui-src/src/views/Contract.vue @@ -288,6 +288,30 @@ const detailTitle = computed(() => detail.value ? detail.value.task.filename : ' display: flex; flex-direction: column; gap: 12px; + height: 100%; + overflow: hidden; +} +.upload-card { + flex-shrink: 0; +} +.task-card { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} +:deep(.el-card__body) { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} +:deep(.el-table) { + flex: 1; + overflow: hidden; +} +:deep(.el-table__body-wrapper) { + overflow-y: auto; } .upload-card :deep(.el-upload) { width: 100%; diff --git a/ui-src/src/views/DatasetDetail.vue b/ui-src/src/views/DatasetDetail.vue index 3d734f0..5db0013 100644 --- a/ui-src/src/views/DatasetDetail.vue +++ b/ui-src/src/views/DatasetDetail.vue @@ -290,11 +290,18 @@ async function saveChunk() { diff --git a/ui-src/src/views/Evidence.vue b/ui-src/src/views/Evidence.vue new file mode 100644 index 0000000..d91e0f8 --- /dev/null +++ b/ui-src/src/views/Evidence.vue @@ -0,0 +1,272 @@ + + + + + diff --git a/ui-src/src/views/KgGraph.vue b/ui-src/src/views/KgGraph.vue index 763c936..6a3edde 100644 --- a/ui-src/src/views/KgGraph.vue +++ b/ui-src/src/views/KgGraph.vue @@ -244,18 +244,26 @@ onMounted(() => { diff --git a/ui-src/src/views/Layout.vue b/ui-src/src/views/Layout.vue index 664d34f..65ba97a 100644 --- a/ui-src/src/views/Layout.vue +++ b/ui-src/src/views/Layout.vue @@ -15,9 +15,13 @@ 知识图谱 - - - 合同标注 + + + 参考案例库 + + + + 我的案件 @@ -40,7 +44,7 @@ + + diff --git a/ui-src/src/views/Settings.vue b/ui-src/src/views/Settings.vue index 74dce79..5e727b8 100644 --- a/ui-src/src/views/Settings.vue +++ b/ui-src/src/views/Settings.vue @@ -174,11 +174,23 @@ async function test(row) { diff --git a/技术设计.md b/技术设计.md index 6673443..8c8aefc 100644 --- a/技术设计.md +++ b/技术设计.md @@ -293,12 +293,14 @@ StartParsePoller(main.go 启动,gtimer 单例 5 秒轮询,job 未结束不 - **条款切分**:按优先级探测三种行首正则(`第X条` / `\d+(.\d+)*[、..]` / `中文数字[、..]`),命中 ≥2 采用,否则整篇单条(title=「全文」);title=标记、content=标记行至下一标记全文 - **召回**:每 dataset 各调 `VecSearch(dsId, vec, 15)` + `FtsSearch(dsId, 分词截 200 字, 15)`,全局按 RRF(`1/(RrfK+rank+1)`) 融合截 `AnnoMaxCandidates=60`;候选 chunk 内容**批量加载**(`ListByIds` 一次 IN 查回内存映射,禁止逐条 GetOne——N+1);embedder 按 dataset 绑定的 embedding 配置构建并缓存;**无候选的条款视为完成**(跳过 LLM 调用,无 mark) -- **判定**:单次 LLM 调用,prompt 含条款全文(截 `AnnoMaxClauseChars=2000`)+ 编号候选,输出 `{"marks":[{"cand_id":1,"law_item":"第四十四条","score":9,"reason":"..."}]}`;**law_item 由 LLM 判定输出**(法条编号,`第X条` 正则兜底提取);JSON 解析与 rerankByLLM 同套路(```json 提取 + 截首尾花括号);**不做门槛过滤,全部候选(含 0 分)按分降序保留** +- **判定**:单次 LLM 调用,prompt 含条款全文(截 `AnnoMaxClauseChars=2000`)+ 编号候选,输出 `{"risks":[{"level":"high|mid|low","desc":"...","laws":[{"cand":1,"law_item":"第九十二条"}]}]}`(无风险输出空数组);**law_item 由 LLM 判定输出**(法条编号,`第X条` 正则兜底提取);**JSON 解析容错**:剥 Markdown 代码围栏 → 截 `{...}` 对象区间解析 → 对象解析失败回退裸数组 `[...]`(按 risks 列表解析,`[]` 即无风险)→ 仍失败才记该条款 failed(Qwen3.5 实测偶发输出裸 `[]`/`[]"`,不按格式包装即解析失败,2026-08-20 修复);**不做门槛过滤,全部候选按分降序保留** - **判定 prompt 上下文预算**:本地对话模型为 Qwen3.5-9B-MLX-4bit,由 oMLX 托管(2026-08-12 起,原 LocalAI/gemma-4-E4B 已卸载;oMLX 单实例 :18080 同时提供 chat+embedding,见 README 模型配置)。oMLX 无 LocalAI 式跨 slot 共享上下文拒收(`max_context_window_policy=16384` 仅按单请求 prompt 上限拒绝,实际 prompt ≤3k token 远低于此);并发请求由调度器排队+轻量批处理,**16GB 机器上并发标注会变慢但不会因上下文被拒**。**Qwen3.5 思考链默认开启且思考文本直接混入 `content` 字段**(非独立 reasoning 字段)——应用侧所有判定/抽取/重排/agent 调用均以 `chat_template_kwargs: {"enable_thinking": false}` 关闭(`DisableThinking`,模板键名在 chat_template.jinja 核实)。**生成预算 `max_tokens=24576`**(判定与知识抽取同值):实测判定自然输出仅 ~900-1100 token,24576 留 20 倍余量、截断物理上不可能。候选块按 `AnnoJudgePromptBudget=1500` 字总预算**贪心填充**(按 RRF 相关度降序,每条截 `AnnoJudgeCandidateChars=300` 字,首条保底入队,超预算截断后续候选)。条文精确文本不依赖 prompt 全文——`extractLawItem` 用未截断的 `ContentFull` 抽取;LLM 引用编号受展示条数约束(越界引用丢弃) +- **oMLX 并发串行,条款判定降为串行**:实测 oMLX 对并发请求**串行调度**(4 个 50-token 小请求总耗时 ≈4 倍单请求时间,典型串行);`pool.annotation_clause` 自 4 降为 **1**(2026-08-20)——串行服务下并发零加速纯排队,8 条条款以 4 并发提交时队尾条款累计等待超过 `chat.timeout` 客户端超时(任务 27 第八条 `context deadline exceeded (Client.Timeout...)`),串行后每条独享 600s 预算不再超时,总耗时与并发排队时相当 - **快照落库**:mark 存命中 chunk 的法条快照(law_title=dataset 名、law_item=LLM 判定的法条编号、content=chunk 内容截断 800 字),标注结果不随语料变更失效 - **批量落库(禁逐条 SQL)**:条款插入与风险快照用 `Batch(100)` 多行 INSERT(GoFrame 一次语句写 100 行,8 列 ≈800 变量 < SQLite 999 上限);进行中/完成状态用 `UpdateStatuses`(WHERE id IN,≤100 分批)各刷一次;进度按**本地计数**每完成一条刷一次 `UpdateProgress`(不再逐条款 `ListByTask` 读库统计)。仅失败路径保留逐条 `UpdateStatus`(error_msg 各异的罕见路径,不批量) - **来源文件标注**:法条引用快照同时记录源文件名(`LawRef.source_file`,如「劳动合同法.pdf」)——law_title 只是 dataset 名(如「法律」),看不出出自哪部法文件,溯源到 chunk 才能定位。判定时按单表约束拆两条 SQL(`kb_chunk` 按 id 批量查 document_id、`kb_document` 按 id 批量查 filename,IN ≤100 分批)内存组装;界面「法律依据」与导出 HTML 显示「来源:xx.pdf」;溯源失败仅告警不阻断标注(展示增强,非判定依据) -- **容错**:单条款 LLM 失败仅该条 failed(记 error_msg)不重试;**任务仍置 Done**,任务 error_msg 记「N 条条款标注失败」(前端可见);未配置默认 chat 模型 → 任务失败 +- **容错**:单条款失败(LLM 判定失败或风险落库失败)仅该条 failed(记 error_msg)不重试;**失败条款计入 done_clauses**(进度按「已处理条款数」计,恒 100%,2026-08-20 修复——此前失败不计数导致任务完成但进度停在 75%);**任一条款失败 → 任务置 Failed**,error_msg 记「N 条条款标注失败」(前端可见,2026-08-20 修复——此前任务无条件置 Done,出现「完成+75%+2 条失败」不一致);全部成功(含无风险条款)任务置 Done;未配置默认 chat 模型 → 任务失败 +- **LLM 调用重试**:`doOpenAIRequest` 实现 `chat.max_retries`(2026-08-20 落实「配置即使用」——此前 config.yml 已配置但代码未实现):仅**连接类瞬时错误**(连接拒绝/重置/EOF)与 **5xx** 重试,指数退避 1/2/4s;**超时类不重试**(`context.DeadlineExceeded` / `net.Error.Timeout`)——超时源于排队或慢响应,重试只会重新排队,成本翻倍 - **断点续跑**:任务按 `status IN (0,1)` 领取,clause 粒度续跑(已 done 不重复产生 mark;重跑任务先 DeleteByClause 幂等重建) - **导出**:`AnnotatedHTML` 生成自包含 HTML(条款 + 内嵌标注,score ≥8 绿 / ≥5 蓝 / 其余灰,打印按钮 `window.print()`);controller 直接写响应体(中间件检测已写入则不包装 JSON),前端原生 fetch + 手动 Authorization 获取 blob