|
|
@@ -244,13 +244,30 @@ public class AiCleanService {
|
|
|
job.setTokenOut(llm.outputTokens());
|
|
|
}
|
|
|
job.setLlmCalls(llmCalls);
|
|
|
- if (outcome.candidate() == null) {
|
|
|
- return new AiJudgeResult.NeedReview("没有合适的模板(含 AI 模板库)");
|
|
|
- }
|
|
|
if (needsStructure(ev)) {
|
|
|
- // 见类注释:本版本不执行结构归一,硬洗会写进错位数据
|
|
|
+ // 见类注释:本版本不执行结构归一,硬洗会写进错位数据。
|
|
|
+ // 结构硬失败优先于「有没有候选」判定:合并单元格的表无论匹配到什么都洗不对
|
|
|
return new AiJudgeResult.NeedReview("该表块需要结构归一(合并单元格/多级表头/整格多字段),暂交人工处理");
|
|
|
}
|
|
|
+ if (outcome.candidate() == null) {
|
|
|
+ // 需求(2026-10-11):完全匹配不到任何模板(含 AI 模板库)时不再转人工,
|
|
|
+ // 按当前文件自身表头新建动态表兜底(全 TEXT、无清洗函数,列名即语义);
|
|
|
+ // 「无法清洗」只留给结构归一、置信度不过线等硬失败
|
|
|
+ List<RuleBind> selfBinds = selfHeaderBinds(headers);
|
|
|
+ if (selfBinds.isEmpty()) {
|
|
|
+ return new AiJudgeResult.NeedReview("表头为空,无法按文件自建表");
|
|
|
+ }
|
|
|
+ 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) {
|
|
|
+ List<RuleBind> stored = store.loadBinds(outcome.candidate().aiTemplateId());
|
|
|
+ if (!stored.isEmpty()) {
|
|
|
+ return new AiJudgeResult.AiPlan(ev, stored, outcome.candidate(), null, headers);
|
|
|
+ }
|
|
|
+ // 存档规则缺失(历史异常数据)→ 落回原路:按本次表头问模型补绑定
|
|
|
+ }
|
|
|
// 选模板那次调用已经把列绑定一起返回了,复用它:单次模型调用实测就要几十秒,
|
|
|
// 为了拿 binds 再打一次等于把延迟和 token 直接翻倍
|
|
|
AiTemplateMatcher.LlmAnswer answer = outcome.draft() != null
|
|
|
@@ -296,14 +313,15 @@ public class AiCleanService {
|
|
|
StrUtil.format("置信度未达出口 {} 的放行线: {}", exit, report.confidence()));
|
|
|
}
|
|
|
if (exit.writesExistingTable()) {
|
|
|
- // main_id / func_regex 都是 file_info 上的瞬态字段,既有链路靠它们选策略,
|
|
|
+ // 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());
|
|
|
+ outcome.candidate().nameCn(),
|
|
|
+ ev.primaryBlock().headerRow());
|
|
|
}
|
|
|
return new AiJudgeResult.AiPlan(ev, binds, outcome.candidate(), draft, headers);
|
|
|
}
|
|
|
@@ -315,7 +333,8 @@ public class AiCleanService {
|
|
|
return switch (result) {
|
|
|
case AiJudgeResult.ExistingHit hit -> {
|
|
|
if (root != null) {
|
|
|
- dispatcher.dispatch(root.getId(), List.of(job.getSheetId()), hit.templateId(), hit.rules());
|
|
|
+ dispatcher.dispatch(root.getId(), List.of(job.getSheetId()), hit.templateId(),
|
|
|
+ hit.rules(), hit.headerRow());
|
|
|
}
|
|
|
job.setRefTemplateId(hit.templateId());
|
|
|
job.setExitCode(AiCleanExitEnum.A.name());
|
|
|
@@ -358,37 +377,58 @@ public class AiCleanService {
|
|
|
List<RuleBind> binds = plan.binds();
|
|
|
List<String> headers = plan.headers();
|
|
|
AiRulePlan.Plan aiPlan = AiRulePlan.forAi(binds, headers, "ai_t");
|
|
|
- // 模板 id 先行:物理表名依赖它;元数据登记挪到写数成功之后
|
|
|
- AiCleanTemplate tpl = new AiCleanTemplate();
|
|
|
- tpl.setId(IdUtil.getSnowflakeNextId());
|
|
|
- tpl.setTableNameEn("ai_t" + tpl.getId());
|
|
|
- tpl.setTableNameCn(StrUtil.blankToDefault(plan.draft().getReason(),
|
|
|
- plan.candidate() == null ? "AI 识别表" : plan.candidate().nameCn()));
|
|
|
- tpl.setHeaders(String.join(",", headers));
|
|
|
- tpl.setHeaderMd5(ev.headerMd5());
|
|
|
- tpl.setHeaderLineNo(ev.primaryBlock().headerRow());
|
|
|
- tpl.setBlockNo(ev.primaryBlock().blockNo());
|
|
|
- tpl.setCategory(FileCategoryEnum.parse(plan.draft().getCategory()).name());
|
|
|
- tpl.setMatchTier(job.getMatchTier());
|
|
|
- tpl.setVer(1);
|
|
|
- tpl.setNeedsStruct(0);
|
|
|
- tpl.setCaseIdSrc(job.getCaseId());
|
|
|
- tpl.setConfidence(job.getConfidence());
|
|
|
- tpl.setCreateTime(LocalDateTime.now());
|
|
|
+ // 同指纹复用:候选来自 AI 模板库时数据并入已有 ai_t 表,不再新建模板 ——
|
|
|
+ // header_md5 唯一键下重复登记会直接撞库(2026-10-11 E2 实测 FAIL)
|
|
|
+ AiCleanTemplate tpl = null;
|
|
|
+ boolean reuse = plan.candidate() != null && plan.candidate().aiTemplateId() != null;
|
|
|
+ if (reuse) {
|
|
|
+ tpl = templateMapper.selectById(plan.candidate().aiTemplateId());
|
|
|
+ reuse = tpl != null && "ONLINE".equals(tpl.getStatus());
|
|
|
+ if (!reuse) {
|
|
|
+ tpl = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
List<AiCleanRule> ruleRows = new ArrayList<>();
|
|
|
- for (RuleBind b : binds) {
|
|
|
- AiCleanRule r = new AiCleanRule();
|
|
|
- r.setColIndex(b.getFileColIndex());
|
|
|
- r.setFieldNameEn(b.getFieldNameEn());
|
|
|
- r.setHints(AiCleanStore.hintsJson(b.getHints()));
|
|
|
- r.setVer(1);
|
|
|
- ruleRows.add(r);
|
|
|
+ if (reuse) {
|
|
|
+ log.info("AI 表指纹命中,复用已有动态表: table={}, templateId={}", tpl.getTableNameEn(), tpl.getId());
|
|
|
+ } else {
|
|
|
+ // 模板 id 先行:物理表名依赖它;元数据登记挪到写数成功之后
|
|
|
+ tpl = new AiCleanTemplate();
|
|
|
+ tpl.setId(IdUtil.getSnowflakeNextId());
|
|
|
+ tpl.setTableNameEn("ai_t" + tpl.getId());
|
|
|
+ // draft 可为空(按文件自身表头自建表的兜底路径没有模型草稿),命名逐级回落
|
|
|
+ String draftReason = plan.draft() == null ? null : plan.draft().getReason();
|
|
|
+ tpl.setTableNameCn(StrUtil.blankToDefault(draftReason,
|
|
|
+ plan.candidate() == null ? "AI 识别表" : plan.candidate().nameCn()));
|
|
|
+ tpl.setHeaders(String.join(",", headers));
|
|
|
+ tpl.setHeaderMd5(ev.headerMd5());
|
|
|
+ tpl.setHeaderLineNo(ev.primaryBlock().headerRow());
|
|
|
+ tpl.setBlockNo(ev.primaryBlock().blockNo());
|
|
|
+ tpl.setCategory(FileCategoryEnum.parse(
|
|
|
+ plan.draft() == null ? null : plan.draft().getCategory()).name());
|
|
|
+ tpl.setMatchTier(job.getMatchTier());
|
|
|
+ tpl.setVer(1);
|
|
|
+ tpl.setNeedsStruct(0);
|
|
|
+ tpl.setCaseIdSrc(job.getCaseId());
|
|
|
+ tpl.setConfidence(job.getConfidence());
|
|
|
+ tpl.setCreateTime(LocalDateTime.now());
|
|
|
+ for (RuleBind b : binds) {
|
|
|
+ AiCleanRule r = new AiCleanRule();
|
|
|
+ r.setColIndex(b.getFileColIndex());
|
|
|
+ r.setFieldNameEn(b.getFieldNameEn());
|
|
|
+ r.setHints(AiCleanStore.hintsJson(b.getHints()));
|
|
|
+ r.setVer(1);
|
|
|
+ ruleRows.add(r);
|
|
|
+ }
|
|
|
}
|
|
|
- String err = AiDuckDb.createAiTable(tpl.getTableNameEn(), aiPlan.fields());
|
|
|
+ String err = reuse ? null : AiDuckDb.createAiTable(tpl.getTableNameEn(), aiPlan.fields());
|
|
|
if (err != null) {
|
|
|
log.warn("AI 动态表建表失败,跳过写入: table={}, err={}", tpl.getTableNameEn(), err);
|
|
|
return -1L;
|
|
|
}
|
|
|
+ // lambda 要求事实最终变量:tpl/reuse 在上面按复用/新建二选一赋值后不再变
|
|
|
+ final AiCleanTemplate tplF = tpl;
|
|
|
+ final boolean reuseF = reuse;
|
|
|
List<String> cols = aiPlan.fields().stream().map(AiCleanField::getColumnName).toList();
|
|
|
long written;
|
|
|
java.util.concurrent.atomic.AtomicLong total = new java.util.concurrent.atomic.AtomicLong();
|
|
|
@@ -409,8 +449,8 @@ public class AiCleanService {
|
|
|
// 旧实现用「清洗后序号 + 表头行」近似,sheet 里有空行时对不上号
|
|
|
cleaned.put("row_no", lineNo);
|
|
|
cleaned.put("ai_job_id", job.getId());
|
|
|
- cleaned.put("ai_rule_ver", tpl.getVer() == null ? 1 : tpl.getVer());
|
|
|
- cleaned.put("category", tpl.getCategory());
|
|
|
+ cleaned.put("ai_rule_ver", tplF.getVer() == null ? 1 : tplF.getVer());
|
|
|
+ cleaned.put("category", tplF.getCategory());
|
|
|
// 写失败会在这里抛出,中断流式读取 —— 半截数据绝不能被当成成功
|
|
|
writer.add(cleaned);
|
|
|
total.incrementAndGet();
|
|
|
@@ -422,13 +462,19 @@ public class AiCleanService {
|
|
|
}
|
|
|
if (written <= 0) {
|
|
|
// 一行没落库不能算成功(含「读到 0 行」):全自动链路没有人看日志,
|
|
|
- // 写成 0 行会被上层当成清洗完成。空表删掉,模板不登记 —— 不留幽灵
|
|
|
- AiDuckDb.dropTable(tpl.getTableNameEn());
|
|
|
+ // 写成 0 行会被上层当成清洗完成。新建表删掉不留幽灵;复用表里还有
|
|
|
+ // 此前批次的数据,绝不能连带删
|
|
|
+ if (!reuse) {
|
|
|
+ AiDuckDb.dropTable(tpl.getTableNameEn());
|
|
|
+ }
|
|
|
return -1L;
|
|
|
}
|
|
|
- // 数据已落盘,现在才登记元数据为 ONLINE 并刷新治理节点(写序收口)
|
|
|
- tpl.setStatus("ONLINE");
|
|
|
- store.register(tpl, aiPlan.fields(), ruleRows);
|
|
|
+ // 数据已落盘,现在才登记元数据为 ONLINE 并刷新治理节点(写序收口);
|
|
|
+ // 复用模板只刷计数,不重复登记
|
|
|
+ if (!reuse) {
|
|
|
+ tpl.setStatus("ONLINE");
|
|
|
+ store.register(tpl, aiPlan.fields(), ruleRows);
|
|
|
+ }
|
|
|
store.refreshTree();
|
|
|
job.setAiTemplateId(tpl.getId());
|
|
|
job.setExitCode(AiCleanExitEnum.B.name());
|
|
|
@@ -475,6 +521,29 @@ public class AiCleanService {
|
|
|
return ev.primaryMergeCount() > 0 || ev.primaryBlock().headerRow() > 1;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 无候选时的兜底绑定:每个非空表头列自绑自身,全 TEXT、无清洗函数 —— 列名即语义。
|
|
|
+ *
|
|
|
+ * <p>需求(2026-10-11):AI 连 AI 模板库都匹配不上时,不再把整张表打入人工,
|
|
|
+ * 而是按当前文件/sheet 原样建表入库;等后续有人工或模型升级再晋升成正式模板。
|
|
|
+ */
|
|
|
+ static List<RuleBind> selfHeaderBinds(List<String> headers) {
|
|
|
+ List<RuleBind> out = new java.util.ArrayList<>();
|
|
|
+ for (int i = 0; i < headers.size(); i++) {
|
|
|
+ String h = headers.get(i);
|
|
|
+ if (StrUtil.isBlank(h)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ RuleBind b = new RuleBind();
|
|
|
+ b.setFileColIndex(i);
|
|
|
+ b.setFieldNameEn(h.trim());
|
|
|
+ b.setHints(List.of());
|
|
|
+ b.setRequired(false);
|
|
|
+ out.add(b);
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
private List<AiTemplateMatcher.AiTemplateWithFields> loadOnlineTemplates() {
|
|
|
List<AiCleanTemplate> templates = templateMapper.selectList(Wrappers.<AiCleanTemplate>lambdaQuery()
|
|
|
.eq(AiCleanTemplate::getStatus, "ONLINE"));
|