|
@@ -294,21 +294,17 @@ public class AiCleanService {
|
|
|
job.setMatchedBy(AiMatchOutcome.BY_SELF_HEADER);
|
|
job.setMatchedBy(AiMatchOutcome.BY_SELF_HEADER);
|
|
|
return new AiJudgeResult.AiPlan(ev, selfBinds, null, null, headers);
|
|
return new AiJudgeResult.AiPlan(ev, selfBinds, null, null, headers);
|
|
|
}
|
|
}
|
|
|
- // 指纹/打分命中 AI 模板库的表:复用存档规则与已有动态表,零模型调用 ——
|
|
|
|
|
- // header_md5 是唯一键,再建同指纹模板会撞 uk_ai_clean_tpl_md5(2026-10-11 实测)
|
|
|
|
|
|
|
+ // 同表头零模型:指纹直通时复用存档规则与已有动态表,一次模型都不调。
|
|
|
|
|
+ // 表头有任何差异(同义词/别名/列序)都不走这里,交给下面由模型判定并出映射。
|
|
|
if (!outcome.exit().writesExistingTable() && outcome.candidate().aiTemplateId() != null) {
|
|
if (!outcome.exit().writesExistingTable() && outcome.candidate().aiTemplateId() != null) {
|
|
|
AiCleanTemplate tpl = templateMapper.selectById(outcome.candidate().aiTemplateId());
|
|
AiCleanTemplate tpl = templateMapper.selectById(outcome.candidate().aiTemplateId());
|
|
|
if (tpl != null && !"ONLINE".equals(tpl.getStatus())) {
|
|
if (tpl != null && !"ONLINE".equals(tpl.getStatus())) {
|
|
|
- // OFFLINE/DRAFT 模板仍占着 header_md5 唯一键,继续新建会撞键并留下幽灵表,
|
|
|
|
|
- // 明确转人工比静默失败好;Phase B 再做重新激活/晋升。
|
|
|
|
|
return new AiJudgeResult.NeedReview("命中 AI 模板但状态为 " + tpl.getStatus()
|
|
return new AiJudgeResult.NeedReview("命中 AI 模板但状态为 " + tpl.getStatus()
|
|
|
+ ",需人工确认后再清洗");
|
|
+ ",需人工确认后再清洗");
|
|
|
}
|
|
}
|
|
|
if (tpl != null) {
|
|
if (tpl != null) {
|
|
|
- // 复用存档绑定的前提是「同序同表头」:打分腿是集合相似度,列序变了仍可能高分,
|
|
|
|
|
- // 直接套旧 col_index 会把整列数据写错位置。
|
|
|
|
|
if (!HeaderFingerprint.sameOrderedHeaders(headers, outcome.candidate().headers())) {
|
|
if (!HeaderFingerprint.sameOrderedHeaders(headers, outcome.candidate().headers())) {
|
|
|
- log.info("AI 模板表头顺序/内容与当前文件不一致,不复用存档绑定: templateId={}", tpl.getId());
|
|
|
|
|
|
|
+ log.info("AI 模板表头与当前文件不一致,交模型判定模板与列映射: templateId={}", tpl.getId());
|
|
|
} else {
|
|
} else {
|
|
|
List<RuleBind> stored = store.loadBinds(tpl.getId());
|
|
List<RuleBind> stored = store.loadBinds(tpl.getId());
|
|
|
List<AiCleanField> storedFields = stored.isEmpty() ? List.of()
|
|
List<AiCleanField> storedFields = stored.isEmpty() ? List.of()
|
|
@@ -365,7 +361,18 @@ public class AiCleanService {
|
|
|
if (binds.isEmpty()) {
|
|
if (binds.isEmpty()) {
|
|
|
return new AiJudgeResult.NeedReview("模型未给出任何可用列绑定");
|
|
return new AiJudgeResult.NeedReview("模型未给出任何可用列绑定");
|
|
|
}
|
|
}
|
|
|
|
|
+ AiCleanExitEnum exit = outcome.exit();
|
|
|
|
|
+ if (!exit.writesExistingTable()) {
|
|
|
|
|
+ // 出口 B(AI 动态表):模型给了模板与列绑定就直接按它入库 —— 不再做抽样复核、
|
|
|
|
|
+ // 不再用置信度/必填覆盖率拦截(2026-10-26 需求)。
|
|
|
|
|
+ job.setConfidence(draft.getConfidence() == null ? null
|
|
|
|
|
+ : java.math.BigDecimal.valueOf(draft.getConfidence()));
|
|
|
|
|
+ log.info("AI 动态表:按模型给出的模板与列绑定入库: templateId={}, binds={}",
|
|
|
|
|
+ outcome.candidate().aiTemplateId(), binds.size());
|
|
|
|
|
+ return new AiJudgeResult.AiPlan(ev, binds, outcome.candidate(), draft, headers);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
|
|
+ // 出口 A(用户既有业务表):写错不可逆,保留抽样复核这道安全垫
|
|
|
mark(job, AiCleanStatusEnum.VERIFYING);
|
|
mark(job, AiCleanStatusEnum.VERIFYING);
|
|
|
List<Map<Integer, String>> sample = loadSample(root, sheet, ev, job);
|
|
List<Map<Integer, String>> sample = loadSample(root, sheet, ev, job);
|
|
|
List<CompiledColumn> verifyColumns = resolveColumns(binds, ev, outcome.candidate(), outcome);
|
|
List<CompiledColumn> verifyColumns = resolveColumns(binds, ev, outcome.candidate(), outcome);
|
|
@@ -379,23 +386,19 @@ public class AiCleanService {
|
|
|
if (report.rejectedCols() != null && !report.rejectedCols().isEmpty()) {
|
|
if (report.rejectedCols() != null && !report.rejectedCols().isEmpty()) {
|
|
|
binds = binds.stream().filter(b -> !report.rejectedCols().contains(b.getFileColIndex())).toList();
|
|
binds = binds.stream().filter(b -> !report.rejectedCols().contains(b.getFileColIndex())).toList();
|
|
|
}
|
|
}
|
|
|
- AiCleanExitEnum exit = outcome.exit();
|
|
|
|
|
if (!report.confidence().pass(exit)) {
|
|
if (!report.confidence().pass(exit)) {
|
|
|
return new AiJudgeResult.NeedReview(
|
|
return new AiJudgeResult.NeedReview(
|
|
|
StrUtil.format("置信度未达出口 {} 的放行线: {}", exit, report.confidence()));
|
|
StrUtil.format("置信度未达出口 {} 的放行线: {}", exit, report.confidence()));
|
|
|
}
|
|
}
|
|
|
- if (exit.writesExistingTable()) {
|
|
|
|
|
- // main_id / func_regex / line_no 都是既有链路要用的字段(瞬态或上传时未落库),
|
|
|
|
|
- // 少带一个就会在 StreamingEtlListener 里 NPE 或 fromCode(null) 抛异常
|
|
|
|
|
- TableInfo hit = GlobalCache.templateTableMap().get(outcome.candidate().templateId());
|
|
|
|
|
- return new AiJudgeResult.ExistingHit(job.getSheetId(), outcome.candidate().templateId(),
|
|
|
|
|
- hit == null ? outcome.candidate().mainId() : hit.getMainId(),
|
|
|
|
|
- hit == null ? null : hit.getFuncRegx(),
|
|
|
|
|
- AiRulePlan.forExisting(binds, outcome.candidate(), headers),
|
|
|
|
|
- outcome.candidate().nameCn(),
|
|
|
|
|
- ev.primaryBlock().headerRow());
|
|
|
|
|
- }
|
|
|
|
|
- return new AiJudgeResult.AiPlan(ev, binds, outcome.candidate(), draft, headers);
|
|
|
|
|
|
|
+ // main_id / func_regex / line_no 都是既有链路要用的字段(瞬态或上传时未落库),
|
|
|
|
|
+ // 少带一个就会在 StreamingEtlListener 里 NPE 或 fromCode(null) 抛异常
|
|
|
|
|
+ TableInfo hit = GlobalCache.templateTableMap().get(outcome.candidate().templateId());
|
|
|
|
|
+ return new AiJudgeResult.ExistingHit(job.getSheetId(), outcome.candidate().templateId(),
|
|
|
|
|
+ hit == null ? outcome.candidate().mainId() : hit.getMainId(),
|
|
|
|
|
+ hit == null ? null : hit.getFuncRegx(),
|
|
|
|
|
+ AiRulePlan.forExisting(binds, outcome.candidate(), headers),
|
|
|
|
|
+ outcome.candidate().nameCn(),
|
|
|
|
|
+ ev.primaryBlock().headerRow());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** 单 job 场景下把判定结论落地(出口 A 自己发清洗、出口 B 自己建表入库) */
|
|
/** 单 job 场景下把判定结论落地(出口 A 自己发清洗、出口 B 自己建表入库) */
|
|
@@ -454,24 +457,23 @@ public class AiCleanService {
|
|
|
SheetEvidence ev = plan.ev();
|
|
SheetEvidence ev = plan.ev();
|
|
|
List<RuleBind> binds = plan.binds();
|
|
List<RuleBind> binds = plan.binds();
|
|
|
List<String> headers = plan.headers();
|
|
List<String> headers = plan.headers();
|
|
|
- // 同指纹复用:候选来自 AI 模板库时数据并入已有 AI 动态表,不再新建模板 ——
|
|
|
|
|
- // header_md5 唯一键下重复登记会直接撞库(2026-10-11 E2 实测 FAIL)
|
|
|
|
|
AiCleanTemplate tpl = null;
|
|
AiCleanTemplate tpl = null;
|
|
|
|
|
+ // 复用有两条口径:
|
|
|
|
|
+ // ① 同表头零模型(judge 已判定指纹直通,plan.draft == null):存档 col_index 即文件列号;
|
|
|
|
|
+ // ② 模型判定(plan.draft != null):按模型给的「文件列 → 字段」映射写入它选中的表。
|
|
|
|
|
+ boolean storedReplay = plan.draft() == null;
|
|
|
boolean reuse = plan.candidate() != null && plan.candidate().aiTemplateId() != null;
|
|
boolean reuse = plan.candidate() != null && plan.candidate().aiTemplateId() != null;
|
|
|
if (reuse) {
|
|
if (reuse) {
|
|
|
tpl = templateMapper.selectById(plan.candidate().aiTemplateId());
|
|
tpl = templateMapper.selectById(plan.candidate().aiTemplateId());
|
|
|
- // 必须「同序同表头」才复用:打分腿是集合相似度,列序变了仍可能命中;
|
|
|
|
|
- // 复用存档 col_index 会把数据写错列,宁可新建模板也不冒这个险。
|
|
|
|
|
- reuse = tpl != null && "ONLINE".equals(tpl.getStatus())
|
|
|
|
|
- && HeaderFingerprint.sameOrderedHeaders(headers, plan.candidate().headers());
|
|
|
|
|
|
|
+ reuse = tpl != null && "ONLINE".equals(tpl.getStatus()) && !binds.isEmpty();
|
|
|
if (!reuse) {
|
|
if (!reuse) {
|
|
|
tpl = null;
|
|
tpl = null;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- // 历史模板只登记了 AI 命中的少数列(如车辆表只有号牌/种类/所有人 3 列),
|
|
|
|
|
- // 与「动态表要还原整张 sheet」的口径不一致:直接复用会让结果页/治理树永远只有那几个字段。
|
|
|
|
|
- // 这里清掉旧模板与旧表,按完整表头重建;同表头的其他文件下次清洗会复用新表。
|
|
|
|
|
- if (reuse) {
|
|
|
|
|
|
|
+ // 历史模板只登记了 AI 命中的少数列(如车辆表只有号牌/种类/所有人 3 列):
|
|
|
|
|
+ // 同表头零模型重放前按完整表头重建(原行为)。模型判定路径写的是存档字段本身,
|
|
|
|
|
+ // 与文件列数无关,不做删表重建。
|
|
|
|
|
+ if (reuse && storedReplay) {
|
|
|
int expectedColumns = AiRulePlan.expandedColumnCount(headers);
|
|
int expectedColumns = AiRulePlan.expandedColumnCount(headers);
|
|
|
Long storedColumns = fieldMapper.selectCount(Wrappers.<AiCleanField>lambdaQuery()
|
|
Long storedColumns = fieldMapper.selectCount(Wrappers.<AiCleanField>lambdaQuery()
|
|
|
.eq(AiCleanField::getAiTemplateId, tpl.getId()));
|
|
.eq(AiCleanField::getAiTemplateId, tpl.getId()));
|
|
@@ -484,16 +486,37 @@ public class AiCleanService {
|
|
|
reuse = false;
|
|
reuse = false;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- AiRulePlan.Plan aiPlan;
|
|
|
|
|
|
|
+ AiRulePlan.Plan aiPlan = null;
|
|
|
List<AiCleanRule> ruleRows = new ArrayList<>();
|
|
List<AiCleanRule> ruleRows = new ArrayList<>();
|
|
|
|
|
+ // 建表用的列定义:复用/新建都先按各自的完整列集建(幂等),写数只写映射到的列
|
|
|
|
|
+ List<AiCleanField> tableFields = null;
|
|
|
if (reuse) {
|
|
if (reuse) {
|
|
|
List<AiCleanField> storedFields = fieldMapper.selectList(Wrappers.<AiCleanField>lambdaQuery()
|
|
List<AiCleanField> storedFields = fieldMapper.selectList(Wrappers.<AiCleanField>lambdaQuery()
|
|
|
.eq(AiCleanField::getAiTemplateId, tpl.getId())
|
|
.eq(AiCleanField::getAiTemplateId, tpl.getId())
|
|
|
.orderByAsc(AiCleanField::getFieldSort));
|
|
.orderByAsc(AiCleanField::getFieldSort));
|
|
|
- aiPlan = AiRulePlan.forStoredTemplate(storedFields, binds);
|
|
|
|
|
- log.info("AI 表指纹命中,复用已有动态表: table={}, templateId={}", tpl.getTableNameEn(), tpl.getId());
|
|
|
|
|
- } else {
|
|
|
|
|
|
|
+ AiRulePlan.Plan planned = storedReplay
|
|
|
|
|
+ ? AiRulePlan.forStoredTemplate(storedFields, binds)
|
|
|
|
|
+ : AiRulePlan.forStoredTemplateByMapping(storedFields, binds);
|
|
|
|
|
+ if (planned.columns().isEmpty()) {
|
|
|
|
|
+ log.info("列绑定没有命中任何存档字段,按未匹配处理并新建表: templateId={}, binds={}",
|
|
|
|
|
+ tpl.getId(), binds.size());
|
|
|
|
|
+ reuse = false;
|
|
|
|
|
+ tpl = null;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ aiPlan = planned;
|
|
|
|
|
+ tableFields = storedFields;
|
|
|
|
|
+ if (storedReplay) {
|
|
|
|
|
+ log.info("同表头零模型复用已有动态表: table={}, templateId={}, 列数={}",
|
|
|
|
|
+ tpl.getTableNameEn(), tpl.getId(), storedFields.size());
|
|
|
|
|
+ } else {
|
|
|
|
|
+ log.info("按模型列映射写入已有动态表: table={}, templateId={}, 写入列={}/{}",
|
|
|
|
|
+ tpl.getTableNameEn(), tpl.getId(), planned.fields().size(), storedFields.size());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!reuse || tpl == null) {
|
|
|
aiPlan = AiRulePlan.forAi(binds, headers, ev.colStats(), plan.candidate());
|
|
aiPlan = AiRulePlan.forAi(binds, headers, ev.colStats(), plan.candidate());
|
|
|
|
|
+ tableFields = aiPlan.fields();
|
|
|
// 模板 id 先行:物理表名依赖它;元数据登记挪到写数成功之后
|
|
// 模板 id 先行:物理表名依赖它;元数据登记挪到写数成功之后
|
|
|
tpl = new AiCleanTemplate();
|
|
tpl = new AiCleanTemplate();
|
|
|
tpl.setId(IdUtil.getSnowflakeNextId());
|
|
tpl.setId(IdUtil.getSnowflakeNextId());
|
|
@@ -531,8 +554,9 @@ public class AiCleanService {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
// 复用模板时物理表可能在当前案件不存在(跨案件共享/被手工删过):
|
|
// 复用模板时物理表可能在当前案件不存在(跨案件共享/被手工删过):
|
|
|
- // CREATE TABLE IF NOT EXISTS 是幂等的,复用也走一次,避免 openWriter 直接失败
|
|
|
|
|
- String err = AiDuckDb.createAiTable(tpl.getTableNameEn(), aiPlan.fields());
|
|
|
|
|
|
|
+ // CREATE TABLE IF NOT EXISTS 是幂等的,复用也走一次,避免 openWriter 直接失败。
|
|
|
|
|
+ // 建表用完整存档列(映射复用只写命中的列,但表结构不能被这次文件裁窄)
|
|
|
|
|
+ String err = AiDuckDb.createAiTable(tpl.getTableNameEn(), tableFields);
|
|
|
if (err != null) {
|
|
if (err != null) {
|
|
|
log.error("AI 动态表建表失败,跳过写入: table={}, err={}", tpl.getTableNameEn(), err);
|
|
log.error("AI 动态表建表失败,跳过写入: table={}, err={}", tpl.getTableNameEn(), err);
|
|
|
return -1L;
|
|
return -1L;
|
|
@@ -553,6 +577,8 @@ public class AiCleanService {
|
|
|
rejectLogSample, rejectLogAll);
|
|
rejectLogSample, rejectLogAll);
|
|
|
// 与手动清洗共用 clean_error_log:导入文件页的「导入失败」弹框直接按批次/文件查这张表
|
|
// 与手动清洗共用 clean_error_log:导入文件页的「导入失败」弹框直接按批次/文件查这张表
|
|
|
List<CleanErrorLog> errorLogs = new ArrayList<>();
|
|
List<CleanErrorLog> errorLogs = new ArrayList<>();
|
|
|
|
|
+ // lambda 捕获需要事实最终变量:aiPlan 在上面按「复用/新建」二选一赋值
|
|
|
|
|
+ final AiRulePlan.Plan aiPlanF = aiPlan;
|
|
|
long written = -1L;
|
|
long written = -1L;
|
|
|
try (AiDuckDb.BatchWriter writer = AiDuckDb.openWriter(tpl.getTableNameEn(), cols)) {
|
|
try (AiDuckDb.BatchWriter writer = AiDuckDb.openWriter(tpl.getTableNameEn(), cols)) {
|
|
|
// 行数据优先取自上传预览表:不用为了入库再把整份源文件重新解析一遍。
|
|
// 行数据优先取自上传预览表:不用为了入库再把整份源文件重新解析一遍。
|
|
@@ -560,7 +586,7 @@ public class AiCleanService {
|
|
|
// 特殊字符剔除、函数链、类型规整都还没做,纯 SQL 的 CREATE TABLE AS 会把这些全部丢掉。
|
|
// 特殊字符剔除、函数链、类型规整都还没做,纯 SQL 的 CREATE TABLE AS 会把这些全部丢掉。
|
|
|
AiDuckDb.RawRowHandler handler = (row, lineNo) -> {
|
|
AiDuckDb.RawRowHandler handler = (row, lineNo) -> {
|
|
|
rejects.countSource();
|
|
rejects.countSource();
|
|
|
- AiRowCleanResult result = AiRecordCleaner.cleanRowDetailed(row, aiPlan.columns());
|
|
|
|
|
|
|
+ AiRowCleanResult result = AiRecordCleaner.cleanRowDetailed(row, aiPlanF.columns());
|
|
|
rejects.record(lineNo, result);
|
|
rejects.record(lineNo, result);
|
|
|
if (result.rejected()) {
|
|
if (result.rejected()) {
|
|
|
errorLogs.add(buildErrorLog(job, lineNo, result.errors(), columnNameCn));
|
|
errorLogs.add(buildErrorLog(job, lineNo, result.errors(), columnNameCn));
|