This commit is contained in:
2026-08-20 12:05:53 +08:00
parent c98fee1bca
commit b13e10d28a
46 changed files with 3026 additions and 87 deletions
+39 -14
View File
@@ -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)是持牌机构内部经营工具,业务词汇(药品/处方/开方)不受此限,但登录页与浏览器标题等可被搜索引擎收录的对外表面保持中性品牌名
+1 -1
View File
@@ -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 并行
BIN
View File
Binary file not shown.
+4
View File
@@ -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
+62
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+1 -14
View File
@@ -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
}
+60
View File
@@ -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
}
@@ -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
}
+100
View File
@@ -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
}
+74
View File
@@ -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
}
+29 -1
View File
@@ -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,
+115
View File
@@ -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
}
+102
View File
@@ -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
}
+71
View File
@@ -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{}
+1
View File
@@ -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:"关联案例ID0=不关联)"`
}
type UploadContractRes struct {
+59
View File
@@ -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:"关联案例ID0=不关联)"`
}
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{}
+65
View File
@@ -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{}
+17
View File
@@ -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"`
}
+10
View File
@@ -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"`
}
+1
View File
@@ -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"`
+18
View File
@@ -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"`
}
+17
View File
@@ -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"`
}
+57 -21
View File
@@ -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
+117
View File
@@ -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
}
+58 -20
View File
@@ -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
}
+17 -1
View File
@@ -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
}
+161
View File
@@ -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)
}
+101
View File
@@ -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
}
+6
View File
@@ -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 文件服务(源文件访问,路径穿越防护) ====================
+25
View File
@@ -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)
}
+21
View File
@@ -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)
}
+21
View File
@@ -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' } })
}
+15
View File
@@ -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 })
+16 -4
View File
@@ -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',
+541
View File
@@ -0,0 +1,541 @@
<template>
<div class="case-detail-page">
<!-- 返回按钮 -->
<div class="detail-head">
<el-button link @click="router.push('/cases')"> 返回我的案件</el-button>
<div class="head-actions">
<el-button type="primary" size="small" @click="openEditDialog">编辑信息</el-button>
</div>
</div>
<template v-if="caseData">
<!-- 基本信息 -->
<el-card shadow="never" class="info-card">
<template #header>
<div class="card-header">
<span>{{ caseData.title }}</span>
<el-tag :type="statusType(caseData.status)" size="small">{{ statusText(caseData.status) }}</el-tag>
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="案号">{{ caseData.case_number || '-' }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ caseData.case_type || '-' }}</el-descriptions-item>
<el-descriptions-item label="受理法院">{{ caseData.court || '-' }}</el-descriptions-item>
<el-descriptions-item label="当事人" :span="2">{{ caseData.parties || '-' }}</el-descriptions-item>
<el-descriptions-item label="案件描述" :span="2">{{ caseData.description || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 证物收集 -->
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span>证物收集{{ evidences.length }}</span>
<el-upload drag :show-file-list="false" :http-request="doUploadEvidence" :disabled="evUploading"
accept=".txt,.md,.pdf,.docx,.doc,.html,.htm,.jpg,.jpeg,.png,.gif,.bmp,.webp,.mp3,.wav,.mp4,.avi,.mov,.mkv"
class="inline-upload">
<el-button link type="primary" :disabled="evUploading">上传证物</el-button>
</el-upload>
</div>
</template>
<el-table :data="evidences" size="small">
<el-table-column prop="filename" label="文件名" min-width="180" show-overflow-tooltip />
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-tag size="small">{{ typeText(row.evidence_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="140" show-overflow-tooltip />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="evStatusType(row.status)" size="small">{{ evStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button link type="danger" @click="removeEvidence(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!evidences.length" description="暂无证物,点击上方上传" :image-size="60" />
</el-card>
<!-- 合同标注 -->
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span>合同标注{{ contracts.length }}</span>
<el-upload drag :show-file-list="false" :http-request="doUploadContract" :disabled="ctUploading"
accept=".txt,.md,.pdf,.docx,.doc,.html,.htm" class="inline-upload">
<el-button link type="primary" :disabled="ctUploading">上传合同</el-button>
</el-upload>
</div>
</template>
<el-table :data="contracts" size="small">
<el-table-column prop="filename" label="合同文件" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" @click="openContractDetail(row)">{{ row.filename }}</el-link>
</template>
</el-table-column>
<el-table-column label="进度" width="160">
<template #default="{ row }">
<el-progress v-if="row.total_clauses > 0" :percentage="Math.round(row.done_clauses * 100 / row.total_clauses)" :stroke-width="12" />
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="ctStatusType(row.status)" size="small">{{ ctStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160" />
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button link type="primary" @click="openContractDetail(row)">详情</el-button>
<el-button link type="danger" @click="removeContract(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!contracts.length" description="暂无合同标注,点击上方上传" :image-size="60" />
</el-card>
</template>
<el-empty v-else-if="!loading" description="案例不存在" />
<!-- 编辑弹窗 -->
<el-dialog v-model="editVisible" title="编辑案例信息" width="560px">
<el-form :model="editForm" label-width="90px">
<el-form-item label="案件名称" required>
<el-input v-model="editForm.title" />
</el-form-item>
<el-form-item label="案号">
<el-input v-model="editForm.case_number" />
</el-form-item>
<el-form-item label="案件类型">
<el-select v-model="editForm.case_type" placeholder="选择类型" style="width: 100%">
<el-option label="民事" value="民事" />
<el-option label="刑事" value="刑事" />
<el-option label="行政" value="行政" />
<el-option label="知识产权" value="知识产权" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="受理法院">
<el-input v-model="editForm.court" />
</el-form-item>
<el-form-item label="当事人">
<el-input v-model="editForm.parties" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="案件描述">
<el-input v-model="editForm.description" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
<!-- 合同标注详情抽屉 -->
<el-drawer v-model="ctDetailVisible" :title="ctDetailTitle" size="80%" destroy-on-close>
<div v-if="ctDetail" class="ct-toolbar">
<el-button type="primary" size="small" @click="openCtSummary" :disabled="ctDetail.task.status !== 2">风险报告</el-button>
<el-button size="small" @click="exportCtAnnotated">导出风险审查版HTML</el-button>
</div>
<div v-if="ctDetail" class="ct-body">
<div class="clause-panel">
<div class="panel-title">合同条款{{ ctDetail.clauses.length }}</div>
<div v-for="cl in ctDetail.clauses" :key="cl.id" class="clause-item"
:class="{ active: cl.id === activeClauseId }" @click="activeClauseId = cl.id">
<el-tag :type="clStatusType(cl.status)" size="small">{{ clStatusText(cl.status) }}</el-tag>
<span class="clause-title">{{ cl.title }}</span>
<div class="clause-preview">{{ cl.content.slice(0, 60) }}</div>
</div>
</div>
<div class="risk-panel">
<template v-if="activeClause">
<div class="panel-title">风险识别{{ (ctRisksFor(activeClause.id) || []).length }}</div>
<div class="clause-full">
<span class="clause-full-title">{{ activeClause.title }}</span>
{{ activeClause.content }}
</div>
<div v-for="r in ctRisksFor(activeClause.id)" :key="r.id" class="risk-card" :class="r.level">
<div class="risk-head">
<el-tag :type="levelType(r.level)" size="small">{{ levelText(r.level) }}</el-tag>
</div>
<div class="risk-desc">{{ r.desc }}</div>
<el-collapse v-if="(r.laws || []).length" class="risk-laws">
<el-collapse-item :title="`法律依据(${r.laws.length}`">
<div v-for="(law, i) in r.laws" :key="i" class="risk-law">
<span class="law-title">{{ law.law_title }}{{ law.law_item }}</span>
<span v-if="law.source_file" class="law-src">来源{{ law.source_file }}</span>
<div class="law-content">{{ law.content }}</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
<el-empty v-if="!(ctRisksFor(activeClause.id) || []).length" description="未发现明显风险" :image-size="60" />
</template>
<el-empty v-else description="点击左侧条款查看风险识别" :image-size="80" />
</div>
</div>
</el-drawer>
<!-- 风险报告弹窗 -->
<el-dialog v-model="ctSummaryVisible" title="合同风险报告" width="760px" top="4vh" destroy-on-close>
<div v-loading="ctSummaryLoading" class="summary-body">
<template v-if="ctSummary">
<div class="summary-stats">
<el-tag type="danger" size="small">高风险 {{ ctSummary.high_count }}</el-tag>
<el-tag type="warning" size="small">中风险 {{ ctSummary.mid_count }}</el-tag>
<el-tag type="primary" size="small">低风险 {{ ctSummary.low_count }}</el-tag>
<el-tag type="info" size="small">无风险条款 {{ ctSummary.no_risk_clauses }} </el-tag>
</div>
<div class="summary-overview">{{ ctSummary.overview }}</div>
<div v-for="lv in ['high', 'mid', 'low']" :key="lv" class="summary-group">
<div v-if="(ctSummary.groups[lv] || []).length" class="summary-group-title" :class="lv">
{{ levelText(lv) }}{{ ctSummary.groups[lv].length }}
</div>
<div v-for="(item, i) in (ctSummary.groups[lv] || [])" :key="i" class="summary-item" :class="lv">
<div class="summary-item-head">
<span class="summary-clause">{{ item.clause_title }}</span>
<el-tag :type="levelType(lv)" size="small">{{ levelText(lv) }}</el-tag>
</div>
<div class="summary-item-desc">{{ item.risks[0].desc }}</div>
<div v-for="(law, j) in (item.risks[0].laws || [])" :key="j" class="summary-law">
<span class="law-title">{{ law.law_title }}{{ law.law_item }}</span>
<span v-if="law.source_file" class="law-src">来源{{ law.source_file }}</span>
</div>
</div>
</div>
<el-empty v-if="!ctSummary.total" description="未发现明显法律风险" :image-size="60" />
</template>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCaseDetail, saveCase } from '../api/case.js'
import { uploadEvidence, deleteEvidence } from '../api/evidence.js'
import { uploadContract, getContractDetail, getContractSummary, deleteContract, exportAnnotatedContract } from '../api/contract.js'
const route = useRoute()
const router = useRouter()
const caseId = Number(route.params.id)
const caseData = ref(null)
const evidences = ref([])
const contracts = ref([])
const loading = ref(false)
// 编辑
const editVisible = ref(false)
const saving = ref(false)
const editForm = ref({})
// 证物上传
const evUploading = ref(false)
// 合同标注
const ctUploading = ref(false)
const ctDetailVisible = ref(false)
const ctDetail = ref(null)
const activeClauseId = ref(null)
const ctSummaryVisible = ref(false)
const ctSummaryLoading = ref(false)
const ctSummary = ref(null)
let ctPollTimer = null
onMounted(async () => {
await loadDetail()
})
onUnmounted(() => { if (ctPollTimer) { clearInterval(ctPollTimer); ctPollTimer = null } })
function statusText(s) { return ['待处理', '进行中', '已结案', '已归档'][s] || '-' }
function statusType(s) { return ['info', 'warning', 'success', 'info'][s] || 'info' }
function typeText(t) { return { document: '文档', image: '图片', video: '音视频' }[t] || t }
function evStatusText(s) { return ['待处理', '解析中', '已完成', '失败'][s] || '-' }
function evStatusType(s) { return ['info', 'warning', 'success', 'danger'][s] || 'info' }
function ctStatusText(s) { return ['待处理', '审查中', '完成', '失败'][s] || '-' }
function ctStatusType(s) { return ['info', 'warning', 'success', 'danger'][s] || 'info' }
function clStatusText(s) { return ['待处理', '审查中', '完成', '失败'][s] || '-' }
function clStatusType(s) { return ['info', 'warning', 'success', 'danger'][s] || 'info' }
function levelText(lv) { return { high: '高风险', mid: '中风险', low: '低风险' }[lv] || lv }
function levelType(lv) { return { high: 'danger', mid: 'warning', low: 'primary' }[lv] || 'info' }
async function loadDetail() {
loading.value = true
try {
const r = await getCaseDetail(caseId)
caseData.value = r.case
evidences.value = r.evidences || []
contracts.value = r.contracts || []
} finally {
loading.value = false
}
}
// 编辑
function openEditDialog() {
editForm.value = { ...caseData.value }
editVisible.value = true
}
async function saveEdit() {
if (!editForm.value.title.trim()) {
ElMessage.warning('请填写案件名称')
return
}
saving.value = true
try {
await saveCase(editForm.value)
ElMessage.success('保存成功')
editVisible.value = false
await loadDetail()
} finally {
saving.value = false
}
}
// 证物上传
async function doUploadEvidence({ file }) {
evUploading.value = true
try {
const formData = new FormData()
formData.append('file', file)
formData.append('case_id', caseId)
if (caseData.value.dataset_ids) {
formData.append('dataset_ids', caseData.value.dataset_ids)
}
await uploadEvidence(formData)
ElMessage.success('上传成功')
await loadDetail()
} finally {
evUploading.value = false
}
}
async function removeEvidence(row) {
try {
await ElMessageBox.confirm(`删除证物「${row.filename}」?`, '确认', { type: 'warning' })
} catch { return }
await deleteEvidence(row.id)
ElMessage.success('已删除')
await loadDetail()
}
// 合同标注上传
async function doUploadContract({ file }) {
ctUploading.value = true
try {
const formData = new FormData()
formData.append('file', file)
formData.append('case_id', caseId)
formData.append('dataset_ids', caseData.value.dataset_ids || '[]')
await uploadContract(formData)
ElMessage.success('已提交风险审查任务')
await loadDetail()
startCtPolling()
} finally {
ctUploading.value = false
}
}
async function removeContract(row) {
try {
await ElMessageBox.confirm('删除合同标注任务及结果?', '确认', { type: 'warning' })
} catch { return }
await deleteContract(row.id)
ElMessage.success('已删除')
await loadDetail()
}
function startCtPolling() {
if (ctPollTimer) { clearInterval(ctPollTimer) }
ctPollTimer = setInterval(async () => {
await loadDetail()
if (!contracts.value.some(t => t.status === 0 || t.status === 1)) {
clearInterval(ctPollTimer)
ctPollTimer = null
}
}, 3000)
}
// 合同详情
async function openContractDetail(row) {
const r = await getContractDetail(row.id)
ctDetail.value = r
activeClauseId.value = (r.clauses && r.clauses.length) ? r.clauses[0].id : null
ctDetailVisible.value = true
if (row.status === 0 || row.status === 1) startCtPolling()
}
const ctDetailTitle = computed(() => ctDetail.value ? ctDetail.value.task.filename : '')
function ctRisksFor(clauseId) {
return (ctDetail.value && ctDetail.value.risks && ctDetail.value.risks[clauseId]) || []
}
async function openCtSummary() {
if (!ctDetail.value) return
ctSummaryVisible.value = true
ctSummaryLoading.value = true
ctSummary.value = null
try {
ctSummary.value = await getContractSummary(ctDetail.value.task.id)
} catch (e) {
ElMessage.error(e.message || '生成报告失败')
} finally {
ctSummaryLoading.value = false
}
}
async function exportCtAnnotated() {
if (!ctDetail.value) return
try {
const blob = await exportAnnotatedContract(ctDetail.value.task.id)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '风险审查-' + ctDetail.value.task.filename.replace(/\.[^.]+$/, '') + '.html'
a.click()
URL.revokeObjectURL(url)
ElMessage.success('已导出')
} catch (e) {
ElMessage.error(e.message || '导出失败')
}
}
</script>
<style scoped>
.case-detail-page {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
overflow-y: auto;
padding: 12px;
}
.detail-head {
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.head-actions { display: flex; gap: 8px; }
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
}
.info-card, .section-card { flex-shrink: 0; }
.inline-upload { display: inline-flex; }
.inline-upload :deep(.el-upload) { display: inline; }
/* 合同标注详情 */
.ct-toolbar {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-bottom: 12px;
}
.ct-body {
display: flex;
gap: 12px;
height: calc(100% - 44px);
}
.clause-panel {
width: 280px;
flex-shrink: 0;
overflow-y: auto;
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 8px;
}
.panel-title {
font-weight: 600;
margin-bottom: 8px;
color: #303133;
}
.clause-item {
padding: 8px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 6px;
border: 1px solid #f0f0f0;
}
.clause-item:hover, .clause-item.active {
border-color: #409eff;
background: #ecf5ff;
}
.clause-title {
font-weight: 600;
color: #409eff;
margin-left: 6px;
}
.clause-preview {
font-size: 12px;
color: #909399;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-panel {
flex: 1;
overflow-y: auto;
}
.clause-full {
background: #fafafa;
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
line-height: 1.8;
color: #606266;
margin-bottom: 12px;
}
.clause-full-title { font-weight: 600; color: #303133; }
.risk-card {
border: 1px solid #e4e7ed;
border-left: 3px solid #909399;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 10px;
background: #fff;
}
.risk-card.high { border-left-color: #f56c6c; }
.risk-card.mid { border-left-color: #e6a23c; }
.risk-card.low { border-left-color: #409eff; }
.risk-head { margin-bottom: 6px; }
.risk-desc { font-size: 13px; color: #303133; line-height: 1.7; margin-bottom: 4px; }
.risk-laws { margin-top: 4px; }
.risk-law { font-size: 12px; color: #606266; line-height: 1.7; margin-bottom: 6px; }
.risk-law .law-title { font-weight: 600; color: #303133; }
.law-src { font-size: 12px; color: #909399; margin-left: 6px; }
.law-content { font-size: 12px; color: #909399; margin-top: 2px; background: #fafafa; border-radius: 4px; padding: 4px 8px; }
/* 风险报告弹窗 */
.summary-body { min-height: 200px; }
.summary-stats { margin-bottom: 12px; display: flex; gap: 8px; }
.summary-overview { background: #f0f9eb; border-left: 3px solid #67c23a; border-radius: 0 6px 6px 0; padding: 10px 12px; font-size: 13px; line-height: 1.8; color: #303133; white-space: pre-wrap; margin-bottom: 16px; }
.summary-group { margin-bottom: 14px; }
.summary-group-title { font-weight: 600; font-size: 14px; margin-bottom: 8px; padding-left: 8px; border-left: 3px solid #909399; }
.summary-group-title.high { border-left-color: #f56c6c; color: #f56c6c; }
.summary-group-title.mid { border-left-color: #e6a23c; color: #e6a23c; }
.summary-group-title.low { border-left-color: #409eff; color: #409eff; }
.summary-item { border: 1px solid #e4e7ed; border-left: 3px solid #909399; border-radius: 6px; padding: 8px 10px; margin-bottom: 8px; background: #fff; }
.summary-item.high { border-left-color: #f56c6c; }
.summary-item.mid { border-left-color: #e6a23c; }
.summary-item.low { border-left-color: #409eff; }
.summary-item-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
.summary-clause { font-weight: 600; font-size: 13px; color: #409eff; }
.summary-item-desc { font-size: 13px; color: #303133; line-height: 1.6; margin-bottom: 4px; }
.summary-law { font-size: 12px; color: #909399; }
.summary-law .law-title { color: #409eff; }
</style>
+179
View File
@@ -0,0 +1,179 @@
<template>
<div class="case-page">
<!-- 操作栏 -->
<div class="section-head">
<el-button type="primary" @click="openDialog()">新增案件</el-button>
</div>
<!-- 案件列表 -->
<el-card class="list-card" shadow="never">
<el-table :data="cases" v-loading="loading" size="default">
<el-table-column prop="case_number" label="案号" width="160" show-overflow-tooltip />
<el-table-column prop="title" label="案件名称" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" @click="goDetail(row)">{{ row.title }}</el-link>
</template>
</el-table-column>
<el-table-column prop="case_type" label="类型" width="100" />
<el-table-column prop="court" label="受理法院" min-width="140" show-overflow-tooltip />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160" />
<el-table-column label="操作" width="140">
<template #default="{ row }">
<el-button link type="primary" @click="goDetail(row)">详情</el-button>
<el-button link type="danger" @click="removeCase(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination class="page-bar" small layout="prev, pager, next" :total="total"
:page-size="pageSize" v-model:current-page="page" @current-change="loadList" />
</el-card>
<!-- 新增弹窗 -->
<el-dialog v-model="dialogVisible" title="新增案件" width="560px">
<el-form :model="form" label-width="90px">
<el-form-item label="案件名称" required>
<el-input v-model="form.title" placeholder="如:张三诉李四合同纠纷案" />
</el-form-item>
<el-form-item label="案号">
<el-input v-model="form.case_number" placeholder="如:(2024)京01民初123号" />
</el-form-item>
<el-form-item label="案件类型">
<el-select v-model="form.case_type" placeholder="选择类型" style="width: 100%">
<el-option label="民事" value="民事" />
<el-option label="刑事" value="刑事" />
<el-option label="行政" value="行政" />
<el-option label="知识产权" value="知识产权" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="受理法院">
<el-input v-model="form.court" placeholder="如:北京市第一中级人民法院" />
</el-form-item>
<el-form-item label="当事人">
<el-input v-model="form.parties" type="textarea" :rows="2" placeholder="原告:张三,被告:李四" />
</el-form-item>
<el-form-item label="案件描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="案件基本情况描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">创建</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listCases, saveCase, deleteCase } from '../api/case.js'
const router = useRouter()
const cases = ref([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = 20
const dialogVisible = ref(false)
const saving = ref(false)
const form = ref({ title: '', case_number: '', case_type: '', court: '', parties: '', description: '', dataset_ids: '', status: 0 })
onMounted(() => loadList())
function statusText(s) { return ['待处理', '进行中', '已结案', '已归档'][s] || '-' }
function statusType(s) { return ['info', 'warning', 'success', 'info'][s] || 'info' }
async function loadList() {
loading.value = true
try {
const r = await listCases({ page: page.value, page_size: pageSize })
if (r && r.list) {
cases.value = r.list
total.value = r.total
}
} finally {
loading.value = false
}
}
function openDialog() {
form.value = { title: '', case_number: '', case_type: '', court: '', parties: '', description: '', dataset_ids: '', status: 0 }
dialogVisible.value = true
}
async function save() {
if (!form.value.title.trim()) {
ElMessage.warning('请填写案件名称')
return
}
saving.value = true
try {
await saveCase(form.value)
ElMessage.success('创建成功')
dialogVisible.value = false
await loadList()
} finally {
saving.value = false
}
}
async function removeCase(row) {
try {
await ElMessageBox.confirm(`删除案例「${row.title}」?`, '删除确认', { type: 'warning' })
} catch { return }
await deleteCase(row.id)
ElMessage.success('已删除')
await loadList()
}
function goDetail(row) {
router.push(`/cases/${row.id}`)
}
</script>
<style scoped>
.case-page {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
overflow: hidden;
}
.section-head {
display: flex;
justify-content: flex-end;
flex-shrink: 0;
}
.list-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;
}
.page-bar {
margin-top: 10px;
justify-content: flex-end;
flex-shrink: 0;
}
</style>
+24
View File
@@ -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%;
+16
View File
@@ -290,11 +290,18 @@ async function saveChunk() {
</script>
<style scoped>
.dd-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.dd-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
flex-shrink: 0;
}
.dd-title {
font-size: 16px;
@@ -302,10 +309,19 @@ async function saveChunk() {
}
.dd-upload {
margin-bottom: 12px;
flex-shrink: 0;
}
.dd-page-bar {
margin-top: 10px;
justify-content: flex-end;
flex-shrink: 0;
}
:deep(.el-table) {
flex: 1;
overflow: hidden;
}
:deep(.el-table__body-wrapper) {
overflow-y: auto;
}
.chunk-drawer :deep(.el-drawer__body) {
overflow: hidden;
+14
View File
@@ -224,12 +224,26 @@ async function remove(row) {
</script>
<style scoped>
.ds-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.ds-head {
margin-bottom: 12px;
flex-shrink: 0;
}
.ds-tip {
font-size: 12px;
color: #909399;
line-height: 1.4;
}
:deep(.el-table) {
flex: 1;
overflow: hidden;
}
:deep(.el-table__body-wrapper) {
overflow-y: auto;
}
</style>
+272
View File
@@ -0,0 +1,272 @@
<template>
<div class="evidence-page">
<!-- 上传区 -->
<el-card class="upload-card" shadow="never">
<div class="upload-row">
<el-select v-model="evidenceType" placeholder="证据类型" style="width: 140px">
<el-option label="文档" value="document" />
<el-option label="图片" value="image" />
<el-option label="音视频" value="video" />
</el-select>
<el-select v-model="dsIds" multiple placeholder="关联法律语料数据集(可多选)" style="width: 320px">
<el-option v-for="d in datasets" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
<el-input v-model="description" placeholder="证据描述(可选)" style="width: 240px" />
<el-upload drag :show-file-list="false" :http-request="doUpload" :disabled="uploading">
<div class="upload-hint">
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div>上传证物文件支持文档/图片/音视频</div>
</div>
</el-upload>
</div>
</el-card>
<!-- 证物列表 -->
<el-card class="list-card" shadow="never">
<template #header><span>证物列表</span></template>
<el-table :data="evidences" v-loading="loading" size="default">
<el-table-column prop="filename" label="文件名" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" @click="openDetail(row)">{{ row.filename }}</el-link>
</template>
</el-table-column>
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-tag size="small">{{ typeText(row.evidence_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column label="关联案例" width="120">
<template #default="{ row }">
<span v-if="row.case_id > 0">案例 #{{ row.case_id }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="合同审计" width="120">
<template #default="{ row }">
<el-link v-if="row.contract_task_id > 0" type="primary" @click="openContract(row)">查看审计</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button link type="primary" @click="openLinkDialog(row)">关联案例</el-button>
<el-button link type="danger" @click="removeEvidence(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination class="page-bar" small layout="prev, pager, next" :total="total"
:page-size="pageSize" v-model:current-page="page" @current-change="loadList" />
</el-card>
<!-- 详情抽屉 -->
<el-drawer v-model="detailVisible" :title="detailFilename" size="60%" destroy-on-close>
<div v-if="detail" class="detail-body">
<el-descriptions :column="2" border>
<el-descriptions-item label="文件名">{{ detail.filename }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ typeText(detail.evidence_type) }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusText(detail.status) }}</el-descriptions-item>
<el-descriptions-item label="关联案例">
<span v-if="detail.case_id > 0">案例 #{{ detail.case_id }}</span>
<span v-else>未关联</span>
</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ detail.description || '-' }}</el-descriptions-item>
<el-descriptions-item label="合同审计" :span="2">
<el-link v-if="detail.contract_task_id > 0" type="primary" @click="openContract(detail)">查看审计结果</el-link>
<span v-else></span>
</el-descriptions-item>
</el-descriptions>
</div>
</el-drawer>
<!-- 关联案例弹窗 -->
<el-dialog v-model="linkDialogVisible" title="关联到案例" width="400px">
<el-select v-model="linkCaseId" placeholder="选择案例" style="width: 100%">
<el-option v-for="c in cases" :key="c.id" :label="`${c.case_number} ${c.title}`" :value="c.id" />
</el-select>
<template #footer>
<el-button @click="linkDialogVisible = false">取消</el-button>
<el-button type="primary" @click="doLink" :disabled="!linkCaseId">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import { uploadEvidence, listEvidence, getEvidenceDetail, deleteEvidence } from '../api/evidence.js'
import { listDatasets } from '../api/dataset.js'
import { listCases, linkEvidence } from '../api/case.js'
const datasets = ref([])
const dsIds = ref([])
const evidenceType = ref('document')
const description = ref('')
const uploading = ref(false)
const evidences = ref([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = 20
const detailVisible = ref(false)
const detail = ref(null)
const detailFilename = ref('')
const linkDialogVisible = ref(false)
const linkCaseId = ref(null)
const linkEvidenceId = ref(null)
const cases = ref([])
onMounted(async () => {
try {
const d = await listDatasets()
if (d && d.list) datasets.value = d.list
} catch { /* 忽略 */ }
await loadList()
})
function typeText(t) {
return { document: '文档', image: '图片', video: '音视频' }[t] || t
}
function statusText(s) {
return ['待处理', '解析中', '已完成', '失败'][s] || '-'
}
function statusType(s) {
return ['info', 'warning', 'success', 'danger'][s] || 'info'
}
async function loadList() {
loading.value = true
try {
const r = await listEvidence({ page: page.value, page_size: pageSize })
if (r && r.list) {
evidences.value = r.list
total.value = r.total
}
} finally {
loading.value = false
}
}
async function doUpload({ file }) {
uploading.value = true
try {
const formData = new FormData()
formData.append('file', file)
formData.append('evidence_type', evidenceType.value)
formData.append('description', description.value)
formData.append('dataset_ids', JSON.stringify(dsIds.value))
await uploadEvidence(formData)
ElMessage.success('证物上传成功')
await loadList()
} catch (e) {
ElMessage.error(e.message || '上传失败')
} finally {
uploading.value = false
}
}
async function openDetail(row) {
const r = await getEvidenceDetail(row.id)
detail.value = r.evidence
detailFilename.value = row.filename
detailVisible.value = true
}
async function removeEvidence(row) {
try {
await ElMessageBox.confirm(`删除证物「${row.filename}」?`, '删除确认', { type: 'warning' })
} catch { return }
await deleteEvidence(row.id)
ElMessage.success('已删除')
await loadList()
}
async function openLinkDialog(row) {
linkEvidenceId.value = row.id
linkCaseId.value = row.case_id || null
try {
const r = await listCases({ page: 1, page_size: 100 })
if (r && r.list) cases.value = r.list
} catch { /* 忽略 */ }
linkDialogVisible.value = true
}
async function doLink() {
if (!linkCaseId.value || !linkEvidenceId.value) return
await linkEvidence({ case_id: linkCaseId.value, evidence_ids: [linkEvidenceId.value] })
ElMessage.success('关联成功')
linkDialogVisible.value = false
await loadList()
}
function openContract(row) {
window.location.hash = '#/contract'
}
</script>
<style scoped>
.evidence-page {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
overflow: hidden;
}
.upload-card {
flex-shrink: 0;
}
.list-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%;
}
.upload-row {
display: flex;
gap: 12px;
align-items: flex-start;
}
.upload-hint {
padding: 6px 0;
}
.upload-icon {
font-size: 40px;
color: #c0c4cc;
}
.page-bar {
margin-top: 10px;
justify-content: flex-end;
flex-shrink: 0;
}
.detail-body {
padding: 0 20px;
}
</style>
+10 -1
View File
@@ -244,18 +244,26 @@ onMounted(() => {
</script>
<style scoped>
.kg-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.kg-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-shrink: 0;
}
.kg-tip {
font-size: 13px;
color: #909399;
}
.kg-graph {
height: 620px;
flex: 1;
min-height: 0;
}
.kg-graph-head {
display: flex;
@@ -265,5 +273,6 @@ onMounted(() => {
.kg-page-bar {
margin-top: 10px;
justify-content: flex-end;
flex-shrink: 0;
}
</style>
+16 -7
View File
@@ -15,9 +15,13 @@
<el-icon><Share /></el-icon>
<span>知识图谱</span>
</el-menu-item>
<el-menu-item index="/contract">
<el-icon><Document /></el-icon>
<span>合同标注</span>
<el-menu-item index="/reference-cases">
<el-icon><Collection /></el-icon>
<span>参考案例库</span>
</el-menu-item>
<el-menu-item index="/cases">
<el-icon><Suitcase /></el-icon>
<span>我的案件</span>
</el-menu-item>
<el-menu-item index="/settings">
<el-icon><Setting /></el-icon>
@@ -40,7 +44,7 @@
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Folder, ChatDotRound, Setting, Share, Document } from '@element-plus/icons-vue'
import { Folder, ChatDotRound, Setting, Share, Collection, Suitcase } from '@element-plus/icons-vue'
import { useAuthStore } from '../stores/auth.js'
const route = useRoute()
@@ -61,9 +65,11 @@ function handleLogout() {
<style scoped>
.layout {
height: 100vh;
overflow: hidden;
}
.aside {
:deep(.el-aside) {
background: #001529;
overflow: hidden;
}
.logo {
color: #fff;
@@ -80,14 +86,17 @@ function handleLogout() {
--el-menu-active-color: #fff;
--el-menu-bg-color: transparent;
}
.header {
:deep(.el-header) {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #eee;
background: #fff;
padding: 0 20px;
}
.main {
:deep(.el-main) {
background: #f5f7fa;
padding: 0;
overflow: hidden;
}
</style>
+416
View File
@@ -0,0 +1,416 @@
<template>
<div class="rc-page">
<!-- ===== 列表视图 ===== -->
<template v-if="!currentId">
<div class="section-head">
<el-button type="primary" @click="openCreateDialog">新建参考案例</el-button>
</div>
<el-card class="list-card" shadow="never">
<el-table :data="cases" v-loading="loading" size="default">
<el-table-column prop="title" label="案件名称" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" @click="openDetail(row)">{{ row.title }}</el-link>
</template>
</el-table-column>
<el-table-column prop="case_number" label="案号" width="180" show-overflow-tooltip />
<el-table-column prop="case_type" label="类型" width="100" />
<el-table-column prop="court" label="受理法院" min-width="160" show-overflow-tooltip />
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination class="page-bar" layout="total, prev, pager, next" :total="total"
:page-size="pageSize" v-model:current-page="page" @current-change="loadList" />
</el-card>
<!-- 新建弹窗 -->
<el-dialog v-model="createVisible" title="新建参考案例" width="560px">
<el-form :model="createForm" label-width="90px">
<el-form-item label="案件名称" required>
<el-input v-model="createForm.title" placeholder="如:张三诉李四合同纠纷案" />
</el-form-item>
<el-form-item label="案号">
<el-input v-model="createForm.case_number" placeholder="如:(2024)京01民初123号" />
</el-form-item>
<el-form-item label="案件类型">
<el-select v-model="createForm.case_type" placeholder="选择类型" style="width: 100%">
<el-option label="民事" value="民事" />
<el-option label="刑事" value="刑事" />
<el-option label="行政" value="行政" />
<el-option label="知识产权" value="知识产权" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="受理法院">
<el-input v-model="createForm.court" placeholder="如:北京市第一中级人民法院" />
</el-form-item>
<el-form-item label="当事人">
<el-input v-model="createForm.parties" type="textarea" :rows="2" placeholder="原告:张三,被告:李四" />
</el-form-item>
<el-form-item label="案件描述">
<el-input v-model="createForm.description" type="textarea" :rows="3" placeholder="案件基本情况" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="doCreate">创建</el-button>
</template>
</el-dialog>
</template>
<!-- ===== 详情视图 ===== -->
<template v-else>
<div class="detail-head">
<el-button link @click="backToList">
<el-icon><ArrowLeft /></el-icon> 返回列表
</el-button>
<span class="detail-title">{{ detailData?.title }}</span>
</div>
<template v-if="detailData">
<!-- 基本信息 -->
<el-card shadow="never" class="info-card">
<template #header>
<div class="card-header">
<span>案件信息</span>
<el-button link type="primary" @click="openEditDialog">编辑</el-button>
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="案号">{{ detailData.case_number || '-' }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ detailData.case_type || '-' }}</el-descriptions-item>
<el-descriptions-item label="受理法院">{{ detailData.court || '-' }}</el-descriptions-item>
<el-descriptions-item label="当事人">{{ detailData.parties || '-' }}</el-descriptions-item>
<el-descriptions-item label="案件描述" :span="2">{{ detailData.description || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 案件文件 -->
<el-card shadow="never" class="doc-card">
<template #header>
<div class="card-header">
<span>案件文件{{ documents.length }}</span>
<el-upload drag :show-file-list="false" :http-request="doUploadDoc" :disabled="uploading"
accept=".txt,.md,.pdf,.docx,.doc,.html,.htm" class="inline-upload">
<el-button link type="primary" :disabled="uploading">上传文件</el-button>
</el-upload>
</div>
</template>
<el-table :data="documents" size="small">
<el-table-column prop="filename" label="文件名" min-width="200" show-overflow-tooltip />
<el-table-column label="大小" width="100">
<template #default="{ row }">{{ formatSize(row.file_size) }}</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tooltip :content="row.error_msg || ''" placement="top">
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="chunk_count" label="分块数" width="90" />
<el-table-column prop="created_at" label="上传时间" width="170" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click="removeDoc(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!documents.length" description="暂无文件,点击上方上传判决书、起诉状等案件材料" :image-size="60" />
</el-card>
</template>
<el-empty v-else-if="!detailLoading" description="案例不存在" />
<!-- 编辑弹窗 -->
<el-dialog v-model="editVisible" title="编辑案例信息" width="560px">
<el-form :model="editForm" label-width="90px">
<el-form-item label="案件名称" required>
<el-input v-model="editForm.title" />
</el-form-item>
<el-form-item label="案号">
<el-input v-model="editForm.case_number" />
</el-form-item>
<el-form-item label="案件类型">
<el-select v-model="editForm.case_type" placeholder="选择类型" style="width: 100%">
<el-option label="民事" value="民事" />
<el-option label="刑事" value="刑事" />
<el-option label="行政" value="行政" />
<el-option label="知识产权" value="知识产权" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="受理法院">
<el-input v-model="editForm.court" />
</el-form-item>
<el-form-item label="当事人">
<el-input v-model="editForm.parties" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="案件描述">
<el-input v-model="editForm.description" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="doEdit">保存</el-button>
</template>
</el-dialog>
</template>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import { listReferenceCases, saveReferenceCase, getReferenceCaseDetail, deleteReferenceCase, uploadReferenceCaseDoc } from '../api/reference_case.js'
import { deleteDocument } from '../api/document.js'
//
const cases = ref([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = 20
//
const createVisible = ref(false)
const creating = ref(false)
const createForm = ref({ title: '', case_number: '', case_type: '', court: '', parties: '', description: '' })
//
const currentId = ref(null)
const detailData = ref(null)
const documents = ref([])
const detailLoading = ref(false)
const uploading = ref(false)
//
const editVisible = ref(false)
const saving = ref(false)
const editForm = ref({})
let pollTimer = null
const statusMap = {
0: { text: '待处理', type: 'info' },
1: { text: '解析中', type: 'warning' },
2: { text: '向量生成中', type: 'warning' },
3: { text: '图谱构建中', type: 'warning' },
4: { text: '已完成', type: 'success' },
5: { text: '失败', type: 'danger' }
}
function statusText(s) { return (statusMap[s] || {}).text || s }
function statusType(s) { return (statusMap[s] || {}).type || 'info' }
function formatSize(bytes) {
if (!bytes) return '-'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
}
onMounted(() => loadList())
onUnmounted(() => { if (pollTimer) { clearInterval(pollTimer); pollTimer = null } })
// ===== =====
async function loadList() {
loading.value = true
try {
const r = await listReferenceCases({ page: page.value, page_size: pageSize })
if (r && r.list) {
cases.value = r.list
total.value = r.total
}
} finally {
loading.value = false
}
}
function openCreateDialog() {
createForm.value = { title: '', case_number: '', case_type: '', court: '', parties: '', description: '' }
createVisible.value = true
}
async function doCreate() {
if (!createForm.value.title.trim()) {
ElMessage.warning('请填写案件名称')
return
}
creating.value = true
try {
const r = await saveReferenceCase(createForm.value)
ElMessage.success('创建成功')
createVisible.value = false
if (r && r.id) openDetail({ id: r.id })
} finally {
creating.value = false
}
}
async function remove(row) {
try {
await ElMessageBox.confirm(`删除参考案例「${row.title}」及其中所有文件?`, '删除确认', { type: 'warning' })
} catch { return }
await deleteReferenceCase(row.id)
ElMessage.success('已删除')
await loadList()
}
// ===== =====
async function openDetail(row) {
currentId.value = row.id
detailLoading.value = true
try {
const r = await getReferenceCaseDetail(row.id)
detailData.value = r.reference_case
documents.value = r.documents || []
startPolling()
} finally {
detailLoading.value = false
}
}
function backToList() {
currentId.value = null
detailData.value = null
documents.value = []
stopPolling()
loadList()
}
async function doUploadDoc({ file }) {
uploading.value = true
try {
const formData = new FormData()
formData.append('id', currentId.value)
formData.append('file', file)
await uploadReferenceCaseDoc(formData)
ElMessage.success('上传成功,正在解析')
await refreshDetail()
startPolling()
} finally {
uploading.value = false
}
}
async function removeDoc(row) {
try {
await ElMessageBox.confirm(`删除文件「${row.filename}」?`, '确认', { type: 'warning' })
} catch { return }
await deleteDocument(row.id)
ElMessage.success('已删除')
await refreshDetail()
}
async function refreshDetail() {
if (!currentId.value) return
const r = await getReferenceCaseDetail(currentId.value)
detailData.value = r.reference_case
documents.value = r.documents || []
}
function startPolling() {
stopPolling()
pollTimer = setInterval(async () => {
await refreshDetail()
if (!documents.value.some(d => d.status < 4)) stopPolling()
}, 3000)
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
// ===== =====
function openEditDialog() {
editForm.value = { ...detailData.value }
editVisible.value = true
}
async function doEdit() {
if (!editForm.value.title.trim()) {
ElMessage.warning('请填写案件名称')
return
}
saving.value = true
try {
await saveReferenceCase(editForm.value)
ElMessage.success('保存成功')
editVisible.value = false
await refreshDetail()
} finally {
saving.value = false
}
}
</script>
<style scoped>
.rc-page {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
overflow: hidden;
}
.section-head {
display: flex;
justify-content: flex-end;
flex-shrink: 0;
}
.list-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;
}
.page-bar {
margin-top: 10px;
justify-content: flex-end;
flex-shrink: 0;
}
/* 详情 */
.detail-head {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.detail-title {
font-size: 16px;
font-weight: 600;
}
.info-card, .doc-card { flex-shrink: 0; }
.doc-card {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
}
.inline-upload { display: inline-flex; }
.inline-upload :deep(.el-upload) { display: inline; }
</style>
+12
View File
@@ -174,11 +174,23 @@ async function test(row) {
<style scoped>
.settings-page {
max-width: 900px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
flex-shrink: 0;
}
:deep(.el-table) {
flex: 1;
overflow: hidden;
}
:deep(.el-table__body-wrapper) {
overflow-y: auto;
}
</style>
+4 -2
View File
@@ -293,12 +293,14 @@ StartParsePollermain.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 token24576 留 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)` 多行 INSERTGoFrame 一次语句写 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