Просмотр исходного кода

chore: auto bump version and changelog [skip ci]

iwt 1 неделя назад
Родитель
Сommit
546e2d5759

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -10507,5 +10507,9 @@ public class DatabaseInitializer implements CommandLineRunner {
         ensureColumn("interaction_logs", "photo_urls", "TEXT COMMENT '照片URL列表(逗号分隔)'");
         ensureColumn("interaction_logs", "video_url", "VARCHAR(500) COMMENT '视频URL'");
         ensureColumn("interaction_logs", "audio_url", "VARCHAR(500) COMMENT '音频URL'");
+
+        // 迁移304: report_type_registry表添加script_status字段(自学习生成采集脚本的状态追踪)
+        ensureColumn("report_type_registry", "script_status",
+                "VARCHAR(20) DEFAULT NULL COMMENT '采集脚本生成状态: generating/ready/failed'");
     }
 }

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParserAdminController.java

@@ -6,6 +6,7 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.*;
 import com.etotem.cfc.service.ReportImportService;
+import com.etotem.cfc.service.ReportScriptGeneratorService;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.slf4j.Logger;
@@ -43,6 +44,9 @@ public class ReportParserAdminController {
     @Resource
     private ReportImportService reportImportService;
 
+    @Resource
+    private ReportScriptGeneratorService reportScriptGeneratorService;
+
     @Resource
     private ReportTemplateMapper reportTemplateMapper;
 
@@ -246,6 +250,9 @@ public class ReportParserAdminController {
             type.setCreatedAt(new Date());
             type.setUpdatedAt(new Date());
             typeRegistryMapper.insert(type);
+            type.setScriptStatus(ReportTypeRegistry.SCRIPT_STATUS_GENERATING);
+            type.setUpdatedAt(new Date());
+            typeRegistryMapper.updateById(type);
 
             // 从公共签名生成指纹规则
             String signatures = cluster.getCommonSignatures();
@@ -296,6 +303,9 @@ public class ReportParserAdminController {
             result.put("displayName", type.getDisplayName());
             result.put("fingerprintCount", type.getMinConfidence());
             result.put("reviewStatus", "pending");
+            result.put("scriptStatus", type.getScriptStatus());
+            // 异步触发 opencode 写采集脚本
+            reportScriptGeneratorService.triggerAsyncGenerate(cluster.getId(), typeId);
             return Result.success(result);
         } catch (Exception e) {
             log.error("自学习生成类型失败 clusterId={}", clusterId, e);

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportTypeRegistry.java

@@ -26,6 +26,7 @@ public class ReportTypeRegistry implements Serializable {
     private String reviewStatus;
     private String version;
     private Long createdBy;
+    private String scriptStatus;
     private Date createdAt;
     private Date updatedAt;
 
@@ -36,4 +37,9 @@ public class ReportTypeRegistry implements Serializable {
     public static final String REVIEW_APPROVED = "approved";
     public static final String REVIEW_PENDING = "pending";
     public static final String REVIEW_REJECTED = "rejected";
+
+    /** 采集脚本生成状态: generating=生成中 / ready=测试通过 / failed=生成或测试失败 */
+    public static final String SCRIPT_STATUS_GENERATING = "generating";
+    public static final String SCRIPT_STATUS_READY = "ready";
+    public static final String SCRIPT_STATUS_FAILED = "failed";
 }

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportFingerprintService.java

@@ -129,8 +129,10 @@ public class ReportFingerprintService {
      */
     private String getScriptPath(String scriptName) {
         String[] searchPaths = {
+            "docs/scripts/parsers/" + scriptName,
             "docs/scripts/" + scriptName,
             "../docs/scripts/" + scriptName,
+            "/app/cfc/docs/scripts/parsers/" + scriptName,
             "/app/cfc/docs/scripts/" + scriptName,
             scriptName,
         };

+ 438 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportScriptGeneratorService.java

@@ -0,0 +1,438 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.ReportTypeRegistry;
+import com.etotem.cfc.entity.ReportUnknownCluster;
+import com.etotem.cfc.entity.ReportUnknownUpload;
+import com.etotem.cfc.mapper.ReportTypeRegistryMapper;
+import com.etotem.cfc.mapper.ReportUnknownClusterMapper;
+import com.etotem.cfc.mapper.ReportUnknownUploadMapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.*;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import javax.annotation.Resource;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+
+@Slf4j
+@Service
+public class ReportScriptGeneratorService {
+
+    @Resource(name = "taskExecutor")
+    private Executor taskExecutor;
+
+    @Resource
+    private ReportUnknownUploadMapper unknownUploadMapper;
+
+    @Resource
+    private ReportTypeRegistryMapper typeRegistryMapper;
+
+    @Resource
+    private ReportUnknownClusterMapper unknownClusterMapper;
+
+    @Resource
+    private StorageService storageService;
+
+    @Resource
+    private RestTemplate restTemplate;
+
+    @Resource
+    private ObjectMapper objectMapper;
+
+    @Value("${report.collect.opencode-base:http://127.0.0.1:4090}")
+    private String opencodeBase;
+
+    @Value("${report.collect.opencode-user:opencode}")
+    private String opencodeUser;
+
+    @Value("${report.collect.opencode-password:IwinTrue@123}")
+    private String opencodePassword;
+
+    @Value("${report.collect.opencode-workdir:/app/cfc}")
+    private String opencodeWorkdir;
+
+    @Value("${report.collect.opencode-model-id:agnes-2.5-flash}")
+    private String opencodeModelId;
+
+    @Value("${report.collect.opencode-model-provider:agnes-ai}")
+    private String opencodeModelProvider;
+
+    @Value("${python.executable:python3}")
+    private String pythonExecutable;
+
+    @Value("${report.parser.scripts.path:docs/scripts/parsers}")
+    private String scriptsPath;
+
+    private static final long SCRIPT_GEN_TIMEOUT_MS = 300_000L;
+    private static final long SCRIPT_TEST_TIMEOUT_MS = 60_000L;
+
+    /**
+     * 异步触发脚本生成。立即返回,结果通过更新 report_type_registry.script_status 通知前端。
+     * 防重入:若已在生成中则跳过。
+     */
+    public void triggerAsyncGenerate(Long clusterId, String typeId) {
+        ReportTypeRegistry type = typeRegistryMapper.selectOne(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ReportTypeRegistry>()
+                        .eq(ReportTypeRegistry::getTypeId, typeId));
+        if (type == null) {
+            log.warn("脚本生成跳过:类型不存在 typeId={}", typeId);
+            return;
+        }
+        if (ReportTypeRegistry.SCRIPT_STATUS_GENERATING.equals(type.getScriptStatus())) {
+            log.info("脚本生成已在进行中,跳过 typeId={}", typeId);
+            return;
+        }
+        taskExecutor.execute(() -> {
+            try {
+                generateAndTest(clusterId, typeId, type.getDisplayName());
+            } catch (Exception e) {
+                log.error("脚本生成异常 typeId={}", typeId, e);
+                setScriptStatus(typeId, ReportTypeRegistry.SCRIPT_STATUS_FAILED);
+            }
+        });
+    }
+
+    private void generateAndTest(Long clusterId, String typeId, String displayName) {
+        ReportUnknownCluster cluster = unknownClusterMapper.selectById(clusterId);
+        if (cluster == null) {
+            log.warn("脚本生成跳过:聚类不存在 clusterId={}", clusterId);
+            setScriptStatus(typeId, ReportTypeRegistry.SCRIPT_STATUS_FAILED);
+            return;
+        }
+
+        List<ReportUnknownUpload> uploads = unknownUploadMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ReportUnknownUpload>()
+                        .eq(ReportUnknownUpload::getClusterId, clusterId)
+                        .orderByDesc(ReportUnknownUpload::getUploadedAt)
+                        .last("LIMIT 1"));
+        if (uploads.isEmpty() || uploads.get(0).getFileUrl() == null) {
+            log.warn("脚本生成跳过:无样例 PDF clusterId={}", clusterId);
+            setScriptStatus(typeId, ReportTypeRegistry.SCRIPT_STATUS_FAILED);
+            return;
+        }
+
+        String samplePdfPath = resolveLocalPath(uploads.get(0).getFileUrl());
+        if (samplePdfPath == null) {
+            log.warn("脚本生成跳过:样例 PDF 本地路径不可达 clusterId={}", clusterId);
+            setScriptStatus(typeId, ReportTypeRegistry.SCRIPT_STATUS_FAILED);
+            return;
+        }
+
+        setScriptStatus(typeId, ReportTypeRegistry.SCRIPT_STATUS_GENERATING);
+
+        String scriptContent;
+        try {
+            scriptContent = invokeOpencodeGenerateScript(typeId, displayName,
+                    cluster.getCommonSignatures(), samplePdfPath);
+        } catch (Exception e) {
+            log.error("opencode 写脚本失败 typeId={}: {}", typeId, e.getMessage());
+            setScriptStatus(typeId, ReportTypeRegistry.SCRIPT_STATUS_FAILED);
+            return;
+        }
+
+        File dir = new File(scriptsPath);
+        if (!dir.exists() && !dir.mkdirs()) {
+            log.warn("无法创建脚本目录: {}", scriptsPath);
+            setScriptStatus(typeId, ReportTypeRegistry.SCRIPT_STATUS_FAILED);
+            return;
+        }
+        File scriptFile = new File(dir, typeId + ".py");
+        try {
+            java.nio.file.Files.write(scriptFile.toPath(),
+                    scriptContent.getBytes(StandardCharsets.UTF_8));
+            log.info("脚本已写入: {}", scriptFile.getAbsolutePath());
+        } catch (Exception e) {
+            log.error("写脚本文件失败 typeId={}: {}", typeId, e.getMessage());
+            setScriptStatus(typeId, ReportTypeRegistry.SCRIPT_STATUS_FAILED);
+            return;
+        }
+
+        try {
+            String stdout = runScriptAndWait(samplePdfPath, scriptFile.getAbsolutePath());
+            validateJsonOutput(stdout, typeId);
+            setScriptStatus(typeId, ReportTypeRegistry.SCRIPT_STATUS_READY);
+            log.info("脚本生成+测试通过 typeId={} scriptPath={}", typeId, scriptFile.getAbsolutePath());
+        } catch (Exception e) {
+            log.warn("脚本独立测试失败 typeId={}: {}", typeId, e.getMessage());
+            setScriptStatus(typeId, ReportTypeRegistry.SCRIPT_STATUS_FAILED);
+        }
+    }
+
+    private String invokeOpencodeGenerateScript(String typeId, String displayName,
+                                                 String commonSignatures, String samplePdfPath)
+            throws Exception {
+        String sessionId = opencodeCreateSession("script-gen-" + typeId);
+        if (sessionId == null) {
+            throw new IllegalStateException("opencode 会话创建失败");
+        }
+
+        String prompt = buildGenerateScriptPrompt(typeId, displayName, commonSignatures, samplePdfPath);
+        sendPrompt(sessionId, prompt);
+
+        long deadline = System.currentTimeMillis() + SCRIPT_GEN_TIMEOUT_MS;
+        int before = countMessages(sessionId);
+        while (System.currentTimeMillis() < deadline) {
+            try {
+                Thread.sleep(5000);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                break;
+            }
+            List<Map<String, Object>> msgs = listMessages(sessionId);
+            if (msgs == null || msgs.size() <= before) {
+                continue;
+            }
+            String reply = findLastAssistantText(msgs);
+            if (reply != null && !reply.trim().isEmpty()) {
+                log.info("opencode 回复(前200字符): {}", reply.substring(0, Math.min(200, reply.length())));
+                String code = extractPythonCode(reply);
+                if (code == null || code.trim().isEmpty()) {
+                    throw new IllegalStateException("opencode 回复中未找到 Python 代码块");
+                }
+                return code;
+            }
+        }
+        throw new IllegalStateException("opencode 写脚本超时(" + SCRIPT_GEN_TIMEOUT_MS / 1000 + "s)");
+    }
+
+    private String buildGenerateScriptPrompt(String typeId, String displayName,
+                                              String commonSignatures, String samplePdfPath) {
+        String desc = (commonSignatures != null && !commonSignatures.isEmpty())
+                ? "公共文本特征:\n" + commonSignatures : "无额外描述";
+        return "你是报告解析脚本编写专家。\n\n"
+                + "任务:为报告类型 '" + typeId + "'(" + (displayName != null ? displayName : "未命名") + ")编写 Python 采集脚本。\n\n"
+                + desc + "\n\n"
+                + "样例PDF路径(本地):" + samplePdfPath + "\n"
+                + "目标脚本路径:/app/cfc/" + scriptsPath + "/" + typeId + ".py\n\n"
+                + "要求:\n"
+                + "1. 先用 pdftotext 或 pdfplumber 读取样例PDF,分析报告结构和字段位置。\n"
+                + "2. 编写 Python 脚本(纯算法,不用 LLM/外部 API),用正则 + pdfplumber/pdfminer 提取结构化数据。\n"
+                + "3. 脚本调用约定:python3 " + typeId + ".py <pdf_path>\n"
+                + "4. stdout 必须输出严格 JSON,结构如下:\n"
+                + "{\n"
+                + "  \"summary\": {\"overallScore\": 0, \"gutHealthScore\": 0, \"reportNumber\": \"\", \"reportDate\": \"\", \"personName\": \"\", \"gender\": \"\", \"age\": 0},\n"
+                + "  \"indicators\": [{\"category\": \"\", \"indicatorName\": \"\", \"indicatorValue\": \"\", \"unit\": \"\", \"refRange\": \"\", \"status\": \"\"}],\n"
+                + "  \"gutFlora\": [{\"bacteriaName\": \"\", \"bacteriaValue\": \"\", \"normalRange\": \"\", \"populationLevel\": \"\", \"detectionRate\": \"\", \"description\": \"\", \"category\": \"\", \"level\": \"\"}],\n"
+                + "  \"foods\": [{\"name\": \"\", \"category\": \"\", \"score\": 0}],\n"
+                + "  \"diseaseRisks\": [{\"diseaseName\": \"\", \"riskValue\": \"\", \"riskLevel\": \"\"}]\n"
+                + "}\n"
+                + "5. 脚本末尾用 try/except 包裹,确保异常时退出码非零。\n"
+                + "6. 写完脚本后,用样例PDF跑一遍(python3 " + typeId + ".py " + samplePdfPath + "),打印 stdout,确认是合法 JSON。\n"
+                + "7. 最后报告脚本路径和测试输出(前500字符)。\n\n"
+                + "注意:只做以上任务。完成后输出完整的 Python 脚本代码(用 ```python 包裹),以及测试结果。";
+    }
+
+    private String runScriptAndWait(String pdfPath, String scriptPath) throws Exception {
+        ProcessBuilder pb = new ProcessBuilder(pythonExecutable, scriptPath, pdfPath);
+        pb.redirectErrorStream(true);
+        Process proc = pb.start();
+        StringBuilder sb = new StringBuilder();
+        try (BufferedReader reader = new BufferedReader(
+                new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8))) {
+            String line;
+            while ((line = reader.readLine()) != null) {
+                sb.append(line).append('\n');
+            }
+        }
+        boolean finished = proc.waitFor(SCRIPT_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+        if (!finished) {
+            proc.destroyForcibly();
+            throw new IllegalStateException("脚本测试超时(" + SCRIPT_TEST_TIMEOUT_MS / 1000 + "s)");
+        }
+        if (proc.exitValue() != 0) {
+            throw new IllegalStateException("脚本测试退出码=" + proc.exitValue()
+                    + "\nstdout: " + sb.toString().trim());
+        }
+        String stdout = sb.toString().trim();
+        if (stdout.isEmpty()) {
+            throw new IllegalStateException("脚本测试 stdout 为空");
+        }
+        return stdout;
+    }
+
+    private void validateJsonOutput(String stdout, String typeId) throws Exception {
+        String json = extractJson(stdout);
+        if (json == null) {
+            throw new IllegalStateException("脚本 stdout 中未找到 JSON\n"
+                    + stdout.substring(0, Math.min(500, stdout.length())));
+        }
+        objectMapper.readTree(json);
+        log.info("脚本测试 JSON 校验通过 typeId={} jsonLen={}", typeId, json.length());
+    }
+
+    private String extractJson(String text) {
+        if (text == null) {
+            return null;
+        }
+        int start = text.indexOf('{');
+        int end = text.lastIndexOf('}');
+        return (start >= 0 && end > start) ? text.substring(start, end + 1) : null;
+    }
+
+    private String opencodeCreateSession(String title) {
+        try {
+            String url = opencodeBase + "/session?directory="
+                    + java.net.URLEncoder.encode(opencodeWorkdir, StandardCharsets.UTF_8.name());
+            Map<String, Object> body = new LinkedHashMap<>();
+            body.put("title", title);
+            body.put("agent", "build");
+            Map<String, Object> model = new LinkedHashMap<>();
+            model.put("id", opencodeModelId);
+            model.put("providerID", opencodeModelProvider);
+            body.put("model", model);
+            Map<String, Object> tools = new LinkedHashMap<>();
+            tools.put("bash", Boolean.TRUE);
+            tools.put("write", Boolean.TRUE);
+            tools.put("edit", Boolean.TRUE);
+            for (String t : new String[]{"apply_patch", "task", "dispatch",
+                    "webfetch_post", "chrome_launch"}) {
+                tools.put(t, Boolean.FALSE);
+            }
+            body.put("tools", tools);
+            ResponseEntity<Map> resp = restTemplate.exchange(url, HttpMethod.POST,
+                    new HttpEntity<>(body, opencodeHeaders()), Map.class);
+            Map<?, ?> data = resp.getBody();
+            if (data == null || data.get("id") == null) {
+                log.warn("opencode 创建会话未返回 session_id");
+                return null;
+            }
+            return data.get("id").toString();
+        } catch (Exception e) {
+            log.error("opencode 创建会话失败 title={}: {}", title, e.getMessage());
+            return null;
+        }
+    }
+
+    private void sendPrompt(String sessionId, String prompt) {
+        List<Map<String, Object>> parts = new ArrayList<>();
+        Map<String, Object> part = new LinkedHashMap<>();
+        part.put("type", "text");
+        part.put("text", prompt);
+        parts.add(part);
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("parts", parts);
+        restTemplate.exchange(opencodeBase + "/session/" + sessionId + "/prompt_async",
+                HttpMethod.POST, new HttpEntity<>(body, opencodeHeaders()), Void.class);
+        log.info("opencode prompt 已发送 session={}", sessionId);
+    }
+
+    private List<Map<String, Object>> listMessages(String sessionId) {
+        try {
+            ResponseEntity<List> resp = restTemplate.exchange(
+                    opencodeBase + "/session/" + sessionId + "/message",
+                    HttpMethod.GET, new HttpEntity<>(opencodeHeaders()), List.class);
+            return resp.getBody();
+        } catch (Exception e) {
+            log.warn("opencode 查询消息失败 session={}: {}", sessionId, e.getMessage());
+            return null;
+        }
+    }
+
+    private int countMessages(String sessionId) {
+        List<Map<String, Object>> msgs = listMessages(sessionId);
+        return msgs == null ? 0 : msgs.size();
+    }
+
+    private HttpHeaders opencodeHeaders() {
+        String token = Base64.getEncoder().encodeToString(
+                (opencodeUser + ":" + opencodePassword).getBytes(StandardCharsets.UTF_8));
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        headers.set("Authorization", "Basic " + token);
+        return headers;
+    }
+
+    @SuppressWarnings("unchecked")
+    private String findLastAssistantText(List<Map<String, Object>> msgs) {
+        if (msgs == null || msgs.isEmpty()) {
+            return null;
+        }
+        for (int i = msgs.size() - 1; i >= 0; i--) {
+            Map<String, Object> msg = msgs.get(i);
+            Object infoObj = msg.get("info");
+            if (!(infoObj instanceof Map)) {
+                continue;
+            }
+            Map<String, Object> info = (Map<String, Object>) infoObj;
+            if (!"assistant".equals(info.get("role"))) {
+                continue;
+            }
+            Object partsObj = msg.get("parts");
+            if (!(partsObj instanceof List)) {
+                continue;
+            }
+            for (Object pObj : (List<Object>) partsObj) {
+                if (!(pObj instanceof Map)) {
+                    continue;
+                }
+                Map<String, Object> p = (Map<String, Object>) pObj;
+                if ("text".equals(p.get("type")) && p.get("text") != null) {
+                    String text = p.get("text").toString().trim();
+                    if (!text.isEmpty()) {
+                        return text;
+                    }
+                }
+            }
+        }
+        return null;
+    }
+
+    private String extractPythonCode(String reply) {
+        if (reply == null) {
+            return null;
+        }
+        int pyStart = reply.indexOf("```python");
+        if (pyStart >= 0) {
+            int codeStart = reply.indexOf('\n', pyStart);
+            int end = reply.lastIndexOf("```");
+            if (end > codeStart) {
+                return reply.substring(codeStart + 1, end).trim();
+            }
+        }
+        int start = reply.indexOf("```");
+        if (start >= 0) {
+            int codeStart = reply.indexOf('\n', start);
+            int end = reply.lastIndexOf("```");
+            if (end > codeStart) {
+                return reply.substring(codeStart + 1, end).trim();
+            }
+        }
+        return reply.trim();
+    }
+
+    private String resolveLocalPath(String fileUrl) {
+        if (fileUrl == null) {
+            return null;
+        }
+        try {
+            String p = storageService.getFilePath(fileUrl);
+            File f = new File(p);
+            return f.exists() ? f.getAbsolutePath() : null;
+        } catch (Exception e) {
+            log.warn("解析文件路径失败 fileUrl={}: {}", fileUrl, e.getMessage());
+            return null;
+        }
+    }
+
+    private void setScriptStatus(String typeId, String scriptStatus) {
+        try {
+            com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ReportTypeRegistry> uw =
+                    new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<>();
+            uw.eq(ReportTypeRegistry::getTypeId, typeId)
+                    .set(ReportTypeRegistry::getScriptStatus, scriptStatus)
+                    .set(ReportTypeRegistry::getUpdatedAt, new Date());
+            typeRegistryMapper.update(null, uw);
+            log.info("脚本状态已更新 typeId={} status={}", typeId, scriptStatus);
+        } catch (Exception e) {
+            log.error("更新脚本状态失败 typeId={}: {}", typeId, e.getMessage());
+        }
+    }
+}

+ 1 - 0
cfc-backend/src/main/resources/schema.sql

@@ -4675,6 +4675,7 @@ CREATE TABLE IF NOT EXISTS report_type_registry (
     review_status VARCHAR(20) DEFAULT 'approved' COMMENT '审核状态: approved/pending/rejected',
     version VARCHAR(20) DEFAULT '1.0' COMMENT '版本',
     created_by BIGINT DEFAULT NULL COMMENT '创建人ID',
+    script_status VARCHAR(20) DEFAULT NULL COMMENT '采集脚本生成状态: generating/ready/failed',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_type_id (type_id)

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-d485c9b9b64d5ef5b15d0f51d87f26cc2bb41f29
+77dcb8d008e37820ca03b841f1907be8798182cb

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1335",
+  "version": "1.0.1336",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1335",
+      "version": "1.0.1336",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1336",
+  "version": "1.0.1337",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 12 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,18 @@
 
 ---
 
+## v1.0.1337 (2026-09-09)
+
+### 新功能
+- 情绪识别接入 DeepFace (LangGraph + 后端接口)
+
+### 其他
+- - cfc-backend: 新增 EmotionRecognitionController(文件上传/URL 两种方式)
+- - cfc-backend: AiGateway 新增 analyzeEmotion / analyzeEmotionByUrl 方法
+- - docs: 新增舌诊情绪识别技术选型报告,更新 API_REFERENCE.md
+- 
+
+
 ## v1.0.1336 (2026-09-08)
 
 ### 新功能

+ 13 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1336
+> 当前版本: v1.0.1337
 
 ## 历史版本
 
@@ -8,6 +8,18 @@
 
 ---
 
+## v1.0.1337 (2026-09-09)
+
+### 新功能
+- 情绪识别接入 DeepFace (LangGraph + 后端接口)
+
+### 其他
+- - cfc-backend: 新增 EmotionRecognitionController(文件上传/URL 两种方式)
+- - cfc-backend: AiGateway 新增 analyzeEmotion / analyzeEmotionByUrl 方法
+- - docs: 新增舌诊情绪识别技术选型报告,更新 API_REFERENCE.md
+- 
+
+
 ## v1.0.1336 (2026-09-08)
 
 ### 新功能

+ 1 - 1
cfc-web/src/views/admin/ReportAutoLearn.vue

@@ -99,7 +99,7 @@ export default {
       this.detailVisible = true
     },
     handleGenerate(row) {
-      this.$confirm('确认为此聚类生成报告类型和指纹规则?生成后需在"报告类型管理"中审核启用。', '提示', { type: 'warning' })
+      this.$confirm('确认为此聚类生成报告类型和指纹规则?系统将自动通过 opencode 生成采集脚本并测试,生成后需在"报告类型管理"中审核启用。', '提示', { type: 'warning' })
         .then(async () => {
           try {
             const res = await generateTypeFromCluster({ clusterId: row.id })

+ 8 - 0
cfc-web/src/views/admin/ReportTypeManagement.vue

@@ -31,6 +31,14 @@
             </el-tag>
           </template>
         </el-table-column>
+        <el-table-column label="脚本状态" width="110">
+          <template slot-scope="{ row }">
+            <el-tag v-if="row.scriptStatus === 'generating'" type="warning" size="small">生成中</el-tag>
+            <el-tag v-else-if="row.scriptStatus === 'ready'" type="success" size="small">测试通过</el-tag>
+            <el-tag v-else-if="row.scriptStatus === 'failed'" type="danger" size="small">生成失败</el-tag>
+            <span v-else style="color:#bbb;">—</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="source" label="来源" width="100" />
         <el-table-column label="操作" width="220" fixed="right">
           <template slot-scope="{ row }">

+ 293 - 0
docs/superpowers/specs/2026-09-09-tianpan-tradition-design.md

@@ -0,0 +1,293 @@
+# 家庭天盘 · 传统文化 + 五维基础能量分 设计文档
+
+- **日期**: 2026-09-09
+- **状态**: 设计待审
+- **需求来源**: 用户「完善家庭天盘的展示设计,用姓名、生日通过生辰八字、星座和数字能量进行天盘内容定义。同时也可以对五维有一个基础能量分的计算」
+
+## 1. 背景与现状
+
+### 1.1 现有实现(已具备)
+
+| 能力 | 位置 | 说明 |
+|------|------|------|
+| 八字四柱 | `family_member_attributes.eight_characters` | JSON `{"year":"甲子","month":"丙寅","day":"戊辰","hour":"壬申"}` |
+| 五行元素 | `family_member_attributes.wuxing_elements` | JSON `{"wood":30,"fire":45,"earth":60,"metal":25,"water":40}` |
+| 生肖 | `family_member_attributes.zodiac` | rat/ox/... |
+| 出生时间 | `family_member_attributes.birth_datetime` | 精确到分钟,用于排盘 |
+| 生命灵数 | `NumSoulCalculator` + `InnatePortraitService.calculateNumSoul()` | lifePath/天赋数/生日数/命运数,主数 11/22/33 保留 |
+| 灵数配置 | `numsoul_detail_config` 表 | 1-9 号人 title/keywords/advice/colorHex |
+| 先天分 | `family_member_attributes.mind_base_score` / `wisdom_base_score` | 仅心/智两维已有 |
+| 天盘聚合 | `TianpanService.buildDashboard()` / `buildMemberDetail()` | 已返回八字/星座/灵数/五行/能量快照 |
+
+### 1.2 缺口
+
+1. 五维基础分仅覆盖 mind/wisdom 两维,缺 body/action/wealth
+2. 天盘主页面 canvas 成员节点未展示传统文化信息
+3. 成员弹窗仅展示生肖/星座/代际/动态能量,无八字/灵数/五维基础分
+4. 无「全家成员传统文化对比」专属页面
+
+### 1.3 设计决策(用户已确认)
+
+1. **展示范围**:以上都要 —— canvas 节点标注 + 成员弹窗增强 + 新增专属页面
+2. **五维基础分算法**:八字五行 + 星座 + 灵数 加权(确定性规则,不走 AI)
+3. **数字能量**:9 型人格数字能量学(即生命灵数体系,复用现有 `NumSoulCalculator`)
+
+## 2. 五维基础能量分算法
+
+### 2.1 核心映射(权威,来自 README 五维五行矩阵)
+
+| 维度 | code | 五行 | 颜色 |
+|:---:|:---:|:---:|:---:|
+| 身 | body | 土 earth | `#FF8C42` |
+| 智 | wisdom | 金 metal | `#6366F1` |
+| 富 | wealth | 水 water | `#F59E0B` |
+| 行 | action | 木 wood | `#10B981` |
+| 心 | mind | 火 fire | `#FF6B9D` |
+
+### 2.2 加权公式
+
+```
+五维基础分[dim] = round( 八字五行[dim] × 0.6  +  星座命中[dim] × 20  +  灵数命中[dim] × 20 )
+```
+
+- 分数范围:每维度 0-100
+- 八字贡献 0-60 分(主因子,占 60%)
+- 星座贡献 0-20 分(占 20%)
+- 灵数贡献 0-20 分(占 20%)
+
+### 2.3 八字五行层(权重 60%)
+
+直接取 `wuxingElements` 中对应五行值(0-100 量纲)乘以 0.6:
+
+| 五行 | 映射维度 |
+|:---:|:---:|
+| wood 木 | action 行 |
+| fire 火 | mind 心 |
+| earth 土 | body 身 |
+| metal 金 | wisdom 智 |
+| water 水 | wealth 富 |
+
+**缺数据兜底**:某成员无 `wuxingElements`(或某五行缺失)时,该维度取中性值 50。
+
+### 2.4 星座层(权重 20%)
+
+西方 12 星座四元素 → 五行 → 五维映射,命中维度 +20 分:
+
+| 四元素 | 星座 | 五行 | 维度 |
+|:---:|------|:---:|:---:|
+| 火象 | 白羊/狮子/射手 | 火 | 心 |
+| 土象 | 金牛/处女/摩羯 | 土 | 身 |
+| 风象 | 双子/天秤/水瓶 | 木 | 行 |
+| 水象 | 巨蟹/天蝎/双鱼 | 水 | 富 |
+
+**边界说明**:西方占星无「金」元素,金(智)在星座层恒 0 贡献。智维度由八字金 + 灵数(3/7)覆盖,不影响整体平衡。星座数据来源:`TianpanService.enrichMembers()` 已通过 `zodiacAnnualEnergyService.getWesternSign(month, day)` 计算 `westernSign`。
+
+**无星座数据兜底**:westernSign 为空时星座层全部 0 贡献。
+
+### 2.5 生命灵数层(权重 20%)
+
+灵数(生命数 lifePath,1-9)映射主维度,命中维度 +20 分:
+
+| 灵数 | 人格特质 | 主维度 |
+|:---:|:---:|:---:|
+| 1 | 开创/独立/领导 | 行 |
+| 2 | 合群/同理/协调 | 心 |
+| 3 | 创意/表达 | 智 |
+| 4 | 务实/规律/稳定 | 身 |
+| 5 | 自由/冒险 | 行 |
+| 6 | 关怀/责任/家庭 | 心 |
+| 7 | 分析/策略/求知 | 智 |
+| 8 | 商业/权力/物质 | 富 |
+| 9 | 博爱/理想 | 心 |
+
+灵数来源:现有 `NumSoulCalculator.calcLifePath(year, month, day)`(主数 11/22/33 保留,需化简为 1-9 用于映射:主数 11→映射 2、22→映射 4、33→映射 6)。
+
+**无灵数数据兜底**:无 birthDatetime 时灵数层全部 0 贡献。
+
+### 2.6 计算示例
+
+输入:八字五行 `{wood:40, fire:60, earth:50, metal:30, water:20}`、星座狮子座(火象→心)、灵数 7(→智)
+
+| 维度 | 八字×0.6 | 星座 | 灵数 | 基础分 |
+|:---:|:---:|:---:|:---:|:---:|
+| 身 body(土) | 50×0.6=30 | 0 | 0 | 30 |
+| 智 wisdom(金) | 30×0.6=18 | 0 | 20 | 38 |
+| 富 wealth(水) | 20×0.6=12 | 0 | 0 | 12 |
+| 行 action(木) | 40×0.6=24 | 0 | 0 | 24 |
+| 心 mind(火) | 60×0.6=36 | 20 | 0 | 56 |
+
+## 3. 后端设计
+
+### 3.1 新增服务 `FiveDimensionScoreService`
+
+文件:`cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionScoreService.java`
+
+```java
+@Service
+public class FiveDimensionScoreService {
+
+    /**
+     * 计算五维基础能量分(确定性规则,无状态)
+     *
+     * @param wuxingElements 五行元素 {"wood":..,"fire":..,"earth":..,"metal":..,"water":..},可为 null
+     * @param westernSign    西方星座中文名(如 "狮子座"),可为 null
+     * @param lifeNumber     生命灵数(主数 11/22/33 已化简为 1-9),可为 null
+     * @return Map<String,Integer> { body, mind, wisdom, action, wealth }
+     */
+    public Map<String, Integer> calcBaseScores(Map<String, Integer> wuxingElements,
+                                               String westernSign,
+                                               Integer lifeNumber) {
+        // 实现要点见 3.1.1 - 3.1.3
+    }
+}
+```
+
+#### 3.1.1 星座 → 四元素映射(内部常量)
+
+```java
+private static final Map<String, String> ZODIAC_ELEMENT = ...;
+// 白羊座/狮子座/射手座 -> FIRE
+// 金牛座/处女座/摩羯座 -> EARTH
+// 双子座/天秤座/水瓶座 -> AIR
+// 巨蟹座/天蝎座/双鱼座 -> WATER
+// 元素 -> 维度: FIRE->mind, EARTH->body, AIR->action, WATER->wealth
+```
+
+#### 3.1.2 灵数 → 主维度映射(内部常量)
+
+```java
+private static final Map<Integer, String> LIFE_NUMBER_DIM = ...;
+// 1->action, 2->mind, 3->wisdom, 4->body, 5->action, 6->mind, 7->wisdom, 8->wealth, 9->mind
+```
+
+#### 3.1.3 计算逻辑
+
+```java
+// 1. 初始化五维 map,八字层: 各维度 = (wuxing 对应五行值 ?? 50) × 0.6
+// 2. 星座命中: elementToDim[westernSign元素] += 20
+// 3. 灵数命中: numberToDim[lifeNumber] += 20
+// 4. round 取整返回
+```
+
+**单测**:`cfc-backend/src/test/java/com/etotem/cfc/unit/FiveDimensionScoreServiceTest.java`
+- 全数据用例(对照 2.6 示例断言精确值)
+- 各层缺数据兜底用例(wuxing=null / westernSign=null / lifeNumber=null)
+- 主数化简用例(11→映射2、22→映射4、33→映射6)
+
+### 3.2 扩展 DTO
+
+`TianpanMemberVO` 新增字段:
+
+```java
+private Map<String, Integer> dimensionBaseScores;  // 五维基础分 {body,mind,wisdom,action,wealth}
+```
+
+### 3.3 改动点
+
+**`TianpanService.enrichMembers()`**(第 174 行附近):
+- 成员已有 `m.getWuxingElements()`、`vo.getWesternSign()`、`m.getLifeNumber()`
+- 在填充 `vo` 时调用 `fiveDimensionScoreService.calcBaseScores(...)` 并 `vo.setDimensionBaseScores(...)`
+
+**`TianpanService.buildMemberDetail()`**:经 `enrichMembers` 自动填充,无需额外改动。
+
+**依赖注入**:`TianpanService` 增加 `@Resource private FiveDimensionScoreService fiveDimensionScoreService;`(注意 Bean 名与字段名一致)。
+
+### 3.4 明确不做的事
+
+- ❌ 不新增数据库表 —— 八字/星座/灵数数据均已存在 `family_member_attributes`,五维基础分为实时计算(确定性、无状态)
+- ❌ 不落库缓存 —— 计算成本极低(纯内存映射),无性能压力
+- ❌ 不新增 Controller 接口 —— 通过现有 `/api/tianpan/dashboard` 和 `/api/tianpan/member/{id}` 返回
+- ❌ 不引入 AI/LangGraph —— 用户已确认走确定性加权计算
+
+## 4. 前端设计(三处增强)
+
+### 4.1 Canvas 成员节点标注(`pages/tianpan/index.vue`)
+
+`drawMemberNodes()`(第 375 行附近):
+- 成员节点圆形下方增加一行小字:`生肖·灵数`(如 `兔·7`)
+- 数据来源:`member.zodiacName`(生肖名)+ `member.lifeNumber`
+- 无生肖/灵数时不显示该行(避免空标注)
+- 颜色:跟随 `getMemberColor(member.effectiveRole)`
+- 字体:9px,与节点名字体一致
+
+**注意**:`TianpanMemberVO` 目前只有 `zodiacName` 字段(生肖名)—— 验证前端现有弹窗用的是 `selectedMember.zodiac`(生肖代码)显示为 `-`,需确认后端返回 `zodiacName` 已正确填充。若后端 `enrichMembers` 未设置 `zodiacName`,需一并修复(见 4.2 数据依赖)。
+
+### 4.2 成员弹窗增强(`pages/tianpan/index.vue` `member-popup`)
+
+弹窗内容重构为两区块:
+
+**① 基本信息区**(现有 + 扩展):
+- 现有:生肖、星座、代际
+- 新增:生命灵数(`member.lifeNumber`,显示 `灵数 N`)、八字四柱(`member.eightCharacters`:年/月/日/时柱横向四格排布,复用 `FamilyTianpanCard` 的 `pillarLabels` 样式)
+- 脱敏:孩子视角查看家长成员时,后端已脱敏为仅年柱(`redactEightCharacters`),前端直接渲染
+
+**② 能量区**(双条对比):
+- 现有:动态能量条(`selectedMember.energy`)
+- 新增:**五维基础分**条(`selectedMember.dimensionBaseScores`,用五维标准色,复用现有 `.energy-bar` 样式)
+
+### 4.3 新增「命理」专属页面 `pages/tianpan/traditional.vue`
+
+**入口**:`pages/tianpan/index.vue` canvas 下方、tab 栏之前新增「家庭成员命理解读」入口卡片(样式参考现有 `fortune-card`)
+
+**页面结构**(全家成员传统文化对比):
+1. **家庭成员列表**:`mapGetters('tianpan', ['members'])` 数据(复用 dashboard 已加载数据,不重复请求)
+2. **每个成员展开卡片**:
+   - 头部:头像 + 昵称 + 角色
+   - 八字四柱:年/月/日/时柱四格
+   - 五行条形图:复用 `FamilyTianpanCard` wuxingData 样式
+   - 星座 + 生肖 tag
+   - 灵数:生命数/天赋数/生日数/命运数(现有 `NumSoulDetailVO` 结构,需确认 dashboard 是否返回完整 numSoul —— 若 `TianpanMemberVO` 只有 lifeNumber 无完整灵数详情,则本页展示简化版:仅生命数)
+   - **五维基础分**:条形图(五维标准色)+ 总分
+
+**页面注册**:`pages.json` 的 `pages/tianpan` 分包新增 `traditional`
+
+**API 去重规范**:本页复用 store `tianpan/members`(dashboard 已加载),禁止重复请求 `/api/tianpan/dashboard`;成员展开明细如需更多数据走 `/api/tianpan/member/{id}`(按需点击加载,遵守规则 4 不 N+1)
+
+### 4.4 数据依赖核对
+
+| 前端展示项 | 后端字段 | 现状 | 动作 |
+|------|------|------|------|
+| 生肖名 | `zodiacName` | `enrichMembers` 需确认是否设置 | 若无则补齐 |
+| 星座 | `westernSign` | ✅ 已设置 | 无 |
+| 灵数 | `lifeNumber` | ✅ 已设置 | 无 |
+| 八字四柱 | `eightCharacters` | ✅ 已设置(含脱敏) | 无 |
+| 五行 | `wuxingElements` | ✅ 已设置 | 无 |
+| 五维基础分 | `dimensionBaseScores` | ❌ 新增 | 3.3 实现 |
+| 灵数详情(天赋/生日/命运数) | numSoul | `TianpanMemberVO` 未含 | 传统页简化版仅展示 lifeNumber |
+
+## 5. 测试策略
+
+### 5.1 后端单测(必须)
+
+`FiveDimensionScoreServiceTest`:
+- 全数据用例(对照 2.6 示例断言)
+- 兜底用例:wuxing=null、westernSign=null、lifeNumber=null、全部为 null(五维全 50×0.6=30)
+- 主数化简用例
+- 星座边界:12 星座全部覆盖 + 未知星座字符串
+
+### 5.2 前端校验
+
+- `node --check` 提取的 script 块语法校验(按 cfc-frontend/AGENTS.md 规范,Agent 不打包)
+- CI 门禁:`node scripts/audit-duplicate-api-calls.js` 通过
+- 小程序限制自查:无可选链 `?.`、无 CSS Grid、无 `:key` 表达式、无 `new Date(string)`
+
+### 5.3 编译验证
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+## 6. 范围边界
+
+**In scope**:
+- 五维基础分算法服务 + 单测
+- `TianpanMemberVO` 扩展 + `enrichMembers` 填充
+- 天盘首页 canvas 节点标注 + 成员弹窗增强
+- 新增 `pages/tianpan/traditional.vue` + 入口卡片 + pages.json 注册
+
+**Out of scope**:
+- 姓名数理(五格剖象法)—— 需汉字笔画库,单独需求
+- 灵数九宫格连线(147/258/369 连线分析)—— 后续迭代
+- AI 天盘解读(LangGraph)—— 未确认前不做
+- 天盘周报 PDF 内容变更
+- 数据库迁移(无新表无新列)