|
@@ -0,0 +1,623 @@
|
|
|
|
|
+package com.etotem.cfc.service;
|
|
|
|
|
+
|
|
|
|
|
+import com.etotem.cfc.dto.ParsedReportPayload;
|
|
|
|
|
+import com.etotem.cfc.entity.*;
|
|
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
|
|
+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.math.BigDecimal;
|
|
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
|
|
+import java.text.SimpleDateFormat;
|
|
|
|
|
+import java.util.*;
|
|
|
|
|
+import java.util.concurrent.Executor;
|
|
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 报告异步采集服务:上传后进入后台队列,指纹判定类型后走专用脚本或 opencode,
|
|
|
|
|
+ * 解析完成自动入库,前端通过草稿 parse_status 轮询。
|
|
|
|
|
+ */
|
|
|
|
|
+@Slf4j
|
|
|
|
|
+@Service
|
|
|
|
|
+public class ReportCollectService {
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ReportFingerprintService fingerprintService;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private HealthReportDraftService healthReportDraftService;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private HealthReportService healthReportService;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private FoodService foodService;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private DimensionScoreService dimensionScoreService;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private FoodRecommendService foodRecommendService;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ReportBlockService reportBlockService;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ReportBlockAssembler reportBlockAssembler;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private StorageService storageService;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource(name = "taskExecutor")
|
|
|
|
|
+ private Executor taskExecutor;
|
|
|
|
|
+
|
|
|
|
|
+ @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;
|
|
|
|
|
+
|
|
|
|
|
+ /** 常量:解析方式 */
|
|
|
|
|
+ public static final String METHOD_FINGERPRINT = "fingerprint_script";
|
|
|
|
|
+ public static final String METHOD_OPENCODE = "opencode";
|
|
|
|
|
+
|
|
|
|
|
+ /** 状态常量:与草稿 parse_status 对齐 */
|
|
|
|
|
+ public static final String STATUS_COLLECTING = "collecting";
|
|
|
|
|
+ public static final String STATUS_COMPLETED = "completed";
|
|
|
|
|
+ public static final String STATUS_FAILED = "failed";
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 提交异步采集任务。上传完成后调用,立即返回。
|
|
|
|
|
+ */
|
|
|
|
|
+ public void submitAsyncCollect(Long draftId) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ taskExecutor.execute(() -> collect(draftId));
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("提交异步采集任务失败 draftId={}", draftId, e);
|
|
|
|
|
+ healthReportDraftService.markCollectFailed(draftId, "提交任务失败: " + e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 采集主流程:指纹判定 → 已知专用脚本 / 未知 opencode → 解析 JSON → 自动入库
|
|
|
|
|
+ */
|
|
|
|
|
+ public void collect(Long draftId) {
|
|
|
|
|
+ HealthReportDraft draft = healthReportDraftService.getDraftById(draftId);
|
|
|
|
|
+ if (draft == null) {
|
|
|
|
|
+ log.warn("异步采集草稿不存在 draftId={}", draftId);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (STATUS_COMPLETED.equals(draft.getParseStatus())) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ healthReportDraftService.markCollecting(draftId, null);
|
|
|
|
|
+ String pdfPath = resolvePdfPath(draft);
|
|
|
|
|
+ if (pdfPath == null) {
|
|
|
|
|
+ healthReportDraftService.markCollectFailed(draftId, "无法获取报告文件路径");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 指纹判定
|
|
|
|
|
+ Map<String, Object> fp = fingerprintService.detect(pdfPath, draft.getOriginalFilename());
|
|
|
|
|
+ String typeId = (String) fp.get("typeId");
|
|
|
|
|
+
|
|
|
|
|
+ ParsedReportPayload.Payload payload;
|
|
|
|
|
+ String method;
|
|
|
|
|
+ if (!"unknown".equals(typeId) && fp.get("parserPath") != null) {
|
|
|
|
|
+ method = METHOD_FINGERPRINT;
|
|
|
|
|
+ payload = parseWithDedicatedScript(pdfPath, (String) fp.get("parserPath"));
|
|
|
|
|
+ } else {
|
|
|
|
|
+ method = METHOD_OPENCODE;
|
|
|
|
|
+ payload = parseWithOpencode(pdfPath, draft.getOriginalFilename());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (payload == null) {
|
|
|
|
|
+ healthReportDraftService.markCollectFailed(draftId, "解析结果为空或无效");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ harmReportPayloadValid(payload);
|
|
|
|
|
+ healthReportDraftService.updateParseMethod(draftId, method);
|
|
|
|
|
+ healthReportDraftService.updatePayload(draftId, objectMapper.writeValueAsString(payload));
|
|
|
|
|
+
|
|
|
|
|
+ insertReport(draft, payload);
|
|
|
|
|
+
|
|
|
|
|
+ log.info("异步采集完成 draftId={}, method={}", draftId, method);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("异步采集失败 draftId={}", draftId, e);
|
|
|
|
|
+ healthReportDraftService.markCollectFailed(draftId, e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void harmReportPayloadValid(ParsedReportPayload.Payload payload) {
|
|
|
|
|
+ if (payload.getSummary() == null && (payload.getIndicators() == null || payload.getIndicators().isEmpty())
|
|
|
|
|
+ && (payload.getGutFlora() == null || payload.getGutFlora().isEmpty())) {
|
|
|
|
|
+ throw new IllegalArgumentException("解析结果中未提取到有效指标数据");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String resolvePdfPath(HealthReportDraft draft) {
|
|
|
|
|
+ if (draft.getFileUrl() == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ String p = storageService.getFilePath(draft.getFileUrl());
|
|
|
|
|
+ File f = new File(p);
|
|
|
|
|
+ return f.exists() ? f.getAbsolutePath() : null;
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("解析文件路径失败 fileUrl={}", draft.getFileUrl(), e);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 已知类型:调专用解析器脚本,期望输出符合 ParsedReportPayload 的 JSON */
|
|
|
|
|
+ private ParsedReportPayload.Payload parseWithDedicatedScript(String pdfPath, String parserPath) {
|
|
|
|
|
+ Map<String, Object> parsed = fingerprintService.runParser(parserPath, pdfPath);
|
|
|
|
|
+ if (parsed == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ JsonNode node = objectMapper.valueToTree(parsed);
|
|
|
|
|
+ return normalizeToPayload(node);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("专用脚本输出转载荷失败 parserPath={}", parserPath, e);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 未知类型:交本地 opencode 服务,提示词带 PDF 内容(后端先 pdftotext 提取文本内联),期望返回结构化 JSON */
|
|
|
|
|
+ private ParsedReportPayload.Payload parseWithOpencode(String pdfPath, String fileName) {
|
|
|
|
|
+ String pdfText = extractPdfText(pdfPath);
|
|
|
|
|
+ String sessionId = opencodeCreateSession();
|
|
|
|
|
+ String prompt = "下面是健康检测报告(文件名:" + (fileName != null ? fileName : pdfPath)
|
|
|
|
|
+ + ")用 pdftotext 提取出的全文文本。请从中提取健康指标数据,并仅返回一个严格符合以下结构的 JSON(不要任何额外文字或 Markdown 包裹):\n"
|
|
|
|
|
+ + "{\"summary\":{\"overallScore\":0,\"gutHealthScore\":0,\"reportNumber\":\"\",\"reportDate\":\"yyyy-MM-dd\",\"personName\":\"\",\"gender\":\"\"},\n"
|
|
|
|
|
+ + "\"indicators\":[{\"category\":\"\",\"indicatorName\":\"\",\"indicatorValue\":\"\",\"unit\":\"\",\"refRange\":\"\",\"status\":\"\"}],\n"
|
|
|
|
|
+ + "\"gutFlora\":[{\"bacteriaName\":\"\",\"bacteriaValue\":\"\",\"normalRange\":\"\",\"populationLevel\":\"\",\"detectionRate\":\"\",\"description\":\"\",\"category\":\"\",\"level\":\"\"}],\n"
|
|
|
|
|
+ + "\"diseaseRisks\":[{\"diseaseName\":\"\",\"riskValue\":\"\",\"riskLevel\":\"\"}],\n"
|
|
|
|
|
+ + "\"foods\":[]}\n"
|
|
|
|
|
+ + "报告全文文本如下:\n" + pdfText + "\n"
|
|
|
|
|
+ + "若无法提取某部分数据返回空数组或 null。";
|
|
|
|
|
+ String reply = opencodeSendAndWait(sessionId, prompt);
|
|
|
|
|
+ if (reply == null || reply.trim().isEmpty()) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ String json = extractJson(reply);
|
|
|
|
|
+ try {
|
|
|
|
|
+ JsonNode node = objectMapper.readTree(json);
|
|
|
|
|
+ return normalizeToPayload(node);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("opencode 输出转载荷失败: {}", e.getMessage());
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 用 pdftotext 提取 PDF 文本,失败时返回可读提示(避免模型误读图片型 PDF) */
|
|
|
|
|
+ private String extractPdfText(String pdfPath) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ File file = new File(pdfPath);
|
|
|
|
|
+ if (!file.exists() || !file.isFile()) {
|
|
|
|
|
+ return "PDF 文件不存在或不是文件:" + pdfPath;
|
|
|
|
|
+ }
|
|
|
|
|
+ ProcessBuilder pb = new ProcessBuilder("pdftotext", "-layout", pdfPath, "-");
|
|
|
|
|
+ pb.redirectErrorStream(true);
|
|
|
|
|
+ Process process = pb.start();
|
|
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
|
|
+ try (BufferedReader reader = new BufferedReader(
|
|
|
|
|
+ new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
|
|
|
|
|
+ String line;
|
|
|
|
|
+ while ((line = reader.readLine()) != null) {
|
|
|
|
|
+ sb.append(line).append('\n');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!process.waitFor(30, TimeUnit.SECONDS)) {
|
|
|
|
|
+ process.destroyForcibly();
|
|
|
|
|
+ log.warn("pdftotext 超时 pdfPath={}", pdfPath);
|
|
|
|
|
+ return "PDF 文本提取超时。" + pdfPath;
|
|
|
|
|
+ }
|
|
|
|
|
+ String text = sb.toString();
|
|
|
|
|
+ if (text.trim().isEmpty()) {
|
|
|
|
|
+ return "该 PDF 可能为扫描图片,无可用文本。" + pdfPath;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 控制提示词体积:仅保留前 60k 字符,超长截断
|
|
|
|
|
+ return text.length() > 60000 ? text.substring(0, 60000) : text;
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("pdftotext 提取失败 pdfPath={}: {}", pdfPath, e.getMessage());
|
|
|
|
|
+ return "PDF 文本提取失败:" + e.getMessage();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 从回复文本中提取 JSON(容忍 code fence 或前导说明) */
|
|
|
|
|
+ private String extractJson(String reply) {
|
|
|
|
|
+ String s = reply.trim();
|
|
|
|
|
+ int start = s.indexOf('{');
|
|
|
|
|
+ int end = s.lastIndexOf('}');
|
|
|
|
|
+ if (start >= 0 && end > start) {
|
|
|
|
|
+ return s.substring(start, end + 1);
|
|
|
|
|
+ }
|
|
|
|
|
+ return s;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 将任意 JSON 节点归一化为 ParsedReportPayload.Payload */
|
|
|
|
|
+ private ParsedReportPayload.Payload normalizeToPayload(JsonNode node) {
|
|
|
|
|
+ return objectMapper.convertValue(node, ParsedReportPayload.Payload.class);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 采集完成后自动入库(复用 confirm 的入库逻辑) */
|
|
|
|
|
+ private void insertReport(HealthReportDraft draft, ParsedReportPayload.Payload payload) {
|
|
|
|
|
+ Long subjectId = null;
|
|
|
|
|
+ if (draft.getFamilyId() != null && payload.getSummary() != null
|
|
|
|
|
+ && payload.getSummary().getPersonName() != null) {
|
|
|
|
|
+ subjectId = healthReportService.matchFamilyMemberByName(
|
|
|
|
|
+ payload.getSummary().getPersonName(), draft.getFamilyId());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ HealthReport report = buildReportFromPayload(draft, payload, subjectId);
|
|
|
|
|
+ List<HealthIndicator> indicators = buildIndicatorsFromPayload(payload);
|
|
|
|
|
+ List<HealthGutFlora> gutFlora = buildGutFloraFromPayload(payload);
|
|
|
|
|
+ List<HealthDiseaseRisk> diseaseRisks = buildDiseaseRisksFromPayload(payload);
|
|
|
|
|
+
|
|
|
|
|
+ HealthReport created = healthReportService.createReport(report, indicators, gutFlora, diseaseRisks);
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ healthReportService.savePayloadSnapshot(created.getId(), draft.getUserId(), "create", null,
|
|
|
|
|
+ objectMapper.writeValueAsString(payload));
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("保存载荷快照失败 reportId={}", created.getId(), e);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (payload.getFoods() != null && !payload.getFoods().isEmpty()) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ List<Food> foods = buildFoodsFromPayload(payload);
|
|
|
|
|
+ foodService.saveFoodsForReport(created.getId(), foods);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("保存食材推荐失败 reportId={}", created.getId(), e);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (subjectId != null) {
|
|
|
|
|
+ healthReportService.bindReportToMember(created.getId(), subjectId);
|
|
|
|
|
+ try {
|
|
|
|
|
+ dimensionScoreService.refreshFromGutReport(subjectId, created.getId());
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("刷新7维健康评分失败 memberId={}", subjectId, e);
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ foodRecommendService.recalculateForUser(subjectId);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("刷新食材推荐指数失败 memberId={}", subjectId, e);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ reportBlockService.save(created.getReportType(), created.getId(),
|
|
|
|
|
+ reportBlockAssembler.assembleWithTemplate(created.getReportType(), payload));
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("保存展示块失败 reportId={}", created.getId(), e);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ healthReportDraftService.markCollectCompleted(draft.getId(), created.getId());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private HealthReport buildReportFromPayload(HealthReportDraft draft, ParsedReportPayload.Payload payload, Long subjectId) {
|
|
|
|
|
+ HealthReport report = new HealthReport();
|
|
|
|
|
+ report.setUserId(draft.getUserId());
|
|
|
|
|
+ report.setFamilyId(draft.getFamilyId());
|
|
|
|
|
+ report.setSubjectId(subjectId);
|
|
|
|
|
+ report.setReportType(draft.getReportType());
|
|
|
|
|
+ report.setFileUrl(draft.getFileUrl());
|
|
|
|
|
+
|
|
|
|
|
+ ParsedReportPayload.Summary s = payload.getSummary();
|
|
|
|
|
+ if (s != null) {
|
|
|
|
|
+ report.setOverallScore(s.getOverallScore());
|
|
|
|
|
+ report.setGutHealthScore(s.getGutHealthScore());
|
|
|
|
|
+ report.setChronicDiseaseScore(s.getChronicDiseaseScore());
|
|
|
|
|
+ report.setNutritionScore(s.getNutritionScore());
|
|
|
|
|
+ report.setBalanceScore(s.getBalanceScore());
|
|
|
|
|
+ report.setDiversityScore(s.getDiversityScore());
|
|
|
|
|
+ report.setBeneficialScore(s.getBeneficialScore());
|
|
|
|
|
+ report.setHarmfulScore(s.getHarmfulScore());
|
|
|
|
|
+ report.setCoreGenusScore(s.getCoreGenusScore());
|
|
|
|
|
+ report.setGutAge(s.getGutAge());
|
|
|
|
|
+ report.setGutType(s.getGutType());
|
|
|
|
|
+ report.setShannonIndex(s.getShannonIndex());
|
|
|
|
|
+ report.setReportNumber(s.getReportNumber());
|
|
|
|
|
+ report.setPersonName(s.getPersonName());
|
|
|
|
|
+ report.setReportDate(parseDate(s.getReportDate()));
|
|
|
|
|
+ }
|
|
|
|
|
+ if (report.getReportDate() == null) {
|
|
|
|
|
+ report.setReportDate(new Date());
|
|
|
|
|
+ }
|
|
|
|
|
+ report.setRemark("异步采集自动入库");
|
|
|
|
|
+ return report;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Date parseDate(String dateStr) {
|
|
|
|
|
+ if (dateStr == null || dateStr.isEmpty()) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
|
|
|
|
|
+ return sdf.parse(dateStr);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ SimpleDateFormat sdf2 = new SimpleDateFormat("yyyy/MM/dd");
|
|
|
|
|
+ return sdf2.parse(dateStr);
|
|
|
|
|
+ } catch (Exception e2) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<HealthIndicator> buildIndicatorsFromPayload(ParsedReportPayload.Payload payload) {
|
|
|
|
|
+ List<ParsedReportPayload.Indicator> list = payload.getIndicators();
|
|
|
|
|
+ if (list == null || list.isEmpty()) return new ArrayList<>();
|
|
|
|
|
+ List<HealthIndicator> indicators = new ArrayList<>();
|
|
|
|
|
+ for (int i = 0; i < list.size(); i++) {
|
|
|
|
|
+ ParsedReportPayload.Indicator pi = list.get(i);
|
|
|
|
|
+ HealthIndicator ind = new HealthIndicator();
|
|
|
|
|
+ ind.setCategory(pi.getCategory());
|
|
|
|
|
+ ind.setIndicatorName(pi.getIndicatorName());
|
|
|
|
|
+ ind.setIndicatorValue(pi.getIndicatorValue());
|
|
|
|
|
+ ind.setUnit(pi.getUnit());
|
|
|
|
|
+ ind.setRefRange(pi.getRefRange());
|
|
|
|
|
+ ind.setStatus(pi.getStatus());
|
|
|
|
|
+ ind.setSymptoms(pi.getSymptoms());
|
|
|
|
|
+ ind.setSortOrder(i);
|
|
|
|
|
+ ind.setCollectionDate(parseDate(pi.getCollectionDate()));
|
|
|
|
|
+ indicators.add(ind);
|
|
|
|
|
+ }
|
|
|
|
|
+ return indicators;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<HealthGutFlora> buildGutFloraFromPayload(ParsedReportPayload.Payload payload) {
|
|
|
|
|
+ List<HealthGutFlora> result = new ArrayList<>();
|
|
|
|
|
+ addFloraList(result, payload.getGutFlora());
|
|
|
|
|
+ addFloraList(result, payload.getProbioticSpecies());
|
|
|
|
|
+ addFloraList(result, payload.getTaxonomyClass());
|
|
|
|
|
+ addFloraList(result, payload.getTaxonomyOrder());
|
|
|
|
|
+ addFloraList(result, payload.getTaxonomyFamily());
|
|
|
|
|
+ addFloraList(result, payload.getTaxonomyGenus());
|
|
|
|
|
+ addFloraList(result, payload.getTaxonomySpecies());
|
|
|
|
|
+ addFloraList(result, payload.getPathogenGenus());
|
|
|
|
|
+ addFloraList(result, payload.getPathogenDetection());
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void addFloraList(List<HealthGutFlora> target, List<ParsedReportPayload.Flora> source) {
|
|
|
|
|
+ if (source != null) {
|
|
|
|
|
+ for (ParsedReportPayload.Flora pf : source) {
|
|
|
|
|
+ HealthGutFlora gf = new HealthGutFlora();
|
|
|
|
|
+ gf.setBacteriaName(pf.getBacteriaName());
|
|
|
|
|
+ gf.setBacteriaValue(pf.getBacteriaValue());
|
|
|
|
|
+ gf.setNormalRange(pf.getNormalRange());
|
|
|
|
|
+ gf.setPopulationLevel(pf.getPopulationLevel());
|
|
|
|
|
+ gf.setDetectionRate(pf.getDetectionRate());
|
|
|
|
|
+ gf.setDescription(pf.getDescription());
|
|
|
|
|
+ gf.setCategory(normalizeFloraCategory(pf.getCategory()));
|
|
|
|
|
+ gf.setLevel(normalizeFloraLevel(pf.getCategory(), pf.getLevel()));
|
|
|
|
|
+ target.add(gf);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String normalizeFloraCategory(String category) {
|
|
|
|
|
+ if (category == null) return null;
|
|
|
|
|
+ if (category.endsWith("构成")) {
|
|
|
|
|
+ return HealthGutFlora.CATEGORY_TAXONOMY;
|
|
|
|
|
+ }
|
|
|
|
|
+ return category;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String normalizeFloraLevel(String category, String level) {
|
|
|
|
|
+ if (level == null) return null;
|
|
|
|
|
+ String normalized = level;
|
|
|
|
|
+ if ("门".equals(level)) normalized = HealthGutFlora.LEVEL_PHYLUM;
|
|
|
|
|
+ else if ("纲".equals(level)) normalized = HealthGutFlora.LEVEL_CLASS;
|
|
|
|
|
+ else if ("目".equals(level)) normalized = HealthGutFlora.LEVEL_ORDER;
|
|
|
|
|
+ else if ("科".equals(level)) normalized = HealthGutFlora.LEVEL_FAMILY;
|
|
|
|
|
+ else if ("属".equals(level)) normalized = HealthGutFlora.LEVEL_GENUS;
|
|
|
|
|
+ else if ("种".equals(level)) normalized = HealthGutFlora.LEVEL_SPECIES;
|
|
|
|
|
+ return normalized;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<HealthDiseaseRisk> buildDiseaseRisksFromPayload(ParsedReportPayload.Payload payload) {
|
|
|
|
|
+ List<ParsedReportPayload.DiseaseRisk> list = payload.getDiseaseRisks();
|
|
|
|
|
+ if (list == null || list.isEmpty()) return new ArrayList<>();
|
|
|
|
|
+ List<HealthDiseaseRisk> risks = new ArrayList<>();
|
|
|
|
|
+ for (ParsedReportPayload.DiseaseRisk pr : list) {
|
|
|
|
|
+ HealthDiseaseRisk dr = new HealthDiseaseRisk();
|
|
|
|
|
+ dr.setDiseaseName(pr.getDiseaseName());
|
|
|
|
|
+ dr.setRiskValue(pr.getRiskValue());
|
|
|
|
|
+ dr.setRiskLevel(pr.getRiskLevel());
|
|
|
|
|
+ risks.add(dr);
|
|
|
|
|
+ }
|
|
|
|
|
+ return risks;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<Food> buildFoodsFromPayload(ParsedReportPayload.Payload payload) {
|
|
|
|
|
+ List<ParsedReportPayload.FoodItem> list = payload.getFoods();
|
|
|
|
|
+ if (list == null || list.isEmpty()) return new ArrayList<>();
|
|
|
|
|
+ List<Food> foods = new ArrayList<>();
|
|
|
|
|
+ for (ParsedReportPayload.FoodItem pf : list) {
|
|
|
|
|
+ Food food = new Food();
|
|
|
|
|
+ food.setName(pf.getName());
|
|
|
|
|
+ food.setCategory(pf.getCategory());
|
|
|
|
|
+ food.setScore(pf.getScore());
|
|
|
|
|
+ food.setEnergyKj(pf.getEnergyKj());
|
|
|
|
|
+ food.setProtein(pf.getProtein());
|
|
|
|
|
+ food.setFat(pf.getFat());
|
|
|
|
|
+ food.setCarbs(pf.getCarbs());
|
|
|
|
|
+ food.setStarch(pf.getStarch());
|
|
|
|
|
+ food.setFiber(pf.getFiber());
|
|
|
|
|
+ food.setCholesterol(pf.getCholesterol());
|
|
|
|
|
+ food.setStatus("recommended");
|
|
|
|
|
+ foods.add(food);
|
|
|
|
|
+ }
|
|
|
|
|
+ return foods;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== opencode 本地服务 HTTP 客户端(协议对齐 运营文案/opencode_client.py) =====
|
|
|
|
|
+
|
|
|
|
|
+ private String opencodeCreateSession() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ String url = opencodeBase + "/session?directory="
|
|
|
|
|
+ + java.net.URLEncoder.encode(opencodeWorkdir, StandardCharsets.UTF_8.name());
|
|
|
|
|
+ Map<String, Object> body = new LinkedHashMap<>();
|
|
|
|
|
+ body.put("title", "report-collect-" + System.currentTimeMillis());
|
|
|
|
|
+ body.put("agent", "report-collect");
|
|
|
|
|
+ // 显式固定模型(Provider/Model id 在 body 中传递,服务端才尊重;query 参数会被忽略)
|
|
|
|
|
+ Map<String, Object> model = new LinkedHashMap<>();
|
|
|
|
|
+ model.put("id", opencodeModelId);
|
|
|
|
|
+ model.put("providerID", opencodeModelProvider);
|
|
|
|
|
+ body.put("model", model);
|
|
|
|
|
+ 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) {
|
|
|
|
|
+ throw new IllegalStateException("opencode 创建会话未返回 session_id");
|
|
|
|
|
+ }
|
|
|
|
|
+ return data.get("id").toString();
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ throw new IllegalStateException("opencode 创建会话失败: " + e.getMessage(), e);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 发送提示词并轮询取回 assistant 最终文本回复 */
|
|
|
|
|
+ private String opencodeSendAndWait(String sessionId, String prompt) {
|
|
|
|
|
+ HttpHeaders headers = opencodeHeaders();
|
|
|
|
|
+ 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);
|
|
|
|
|
+ // 只读约束:仅允许分析/读取,禁用执行/写入类工具(与本地运营脚本对齐)
|
|
|
|
|
+ Map<String, Object> tools = new LinkedHashMap<>();
|
|
|
|
|
+ for (String t : new String[]{"bash", "shell", "write", "edit", "apply_patch",
|
|
|
|
|
+ "task", "dispatch", "webfetch_post", "chrome_launch"}) {
|
|
|
|
|
+ tools.put(t, Boolean.FALSE);
|
|
|
|
|
+ }
|
|
|
|
|
+ body.put("tools", tools);
|
|
|
|
|
+ restTemplate.exchange(opencodeBase + "/session/" + sessionId + "/prompt_async",
|
|
|
|
|
+ HttpMethod.POST, new HttpEntity<>(body, headers), Void.class);
|
|
|
|
|
+
|
|
|
|
|
+ long deadline = System.currentTimeMillis() + 180000;
|
|
|
|
|
+ long pollInterval = 5000;
|
|
|
|
|
+ int before = countMessages(sessionId);
|
|
|
|
|
+ while (System.currentTimeMillis() < deadline) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ Thread.sleep(pollInterval);
|
|
|
|
|
+ } catch (InterruptedException e) {
|
|
|
|
|
+ Thread.currentThread().interrupt();
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ List<Map<String, Object>> msgs = listMessages(sessionId);
|
|
|
|
|
+ if (msgs == null || msgs.isEmpty()) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ Map<String, Object> last = msgs.get(msgs.size() - 1);
|
|
|
|
|
+ String role = roleOf(last);
|
|
|
|
|
+ if ("assistant".equals(role)) {
|
|
|
|
|
+ String text = lastAssistantText(last);
|
|
|
|
|
+ if (text != null && !text.trim().isEmpty()) {
|
|
|
|
|
+ return text;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (msgs.size() > before) {
|
|
|
|
|
+ for (int i = msgs.size() - 1; i >= 0; i--) {
|
|
|
|
|
+ String r = roleOf(msgs.get(i));
|
|
|
|
|
+ if ("assistant".equals(r)) {
|
|
|
|
|
+ String t = lastAssistantText(msgs.get(i));
|
|
|
|
|
+ if (t != null && !t.trim().isEmpty()) {
|
|
|
|
|
+ return t;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ throw new IllegalStateException("等待 opencode 回复超时(180s)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ private String roleOf(Map<String, Object> msg) {
|
|
|
|
|
+ Map<String, Object> info = (Map<String, Object>) msg.get("info");
|
|
|
|
|
+ return info != null ? (String) info.get("role") : null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ private String lastAssistantText(Map<String, Object> msg) {
|
|
|
|
|
+ Object partsObj = msg.get("parts");
|
|
|
|
|
+ if (!(partsObj instanceof List)) return null;
|
|
|
|
|
+ List<Object> parts = (List<Object>) partsObj;
|
|
|
|
|
+ String text = null;
|
|
|
|
|
+ for (Object pObj : parts) {
|
|
|
|
|
+ if (pObj instanceof Map) {
|
|
|
|
|
+ Map<String, Object> p = (Map<String, Object>) pObj;
|
|
|
|
|
+ if ("text".equals(p.get("type")) && p.get("text") != null) {
|
|
|
|
|
+ text = p.get("text").toString();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return text;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private int countMessages(String sessionId) {
|
|
|
|
|
+ List<Map<String, Object>> msgs = listMessages(sessionId);
|
|
|
|
|
+ return msgs == null ? 0 : msgs.size();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ 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);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|