6142 lines
284 KiB
C#
6142 lines
284 KiB
C#
using System.Diagnostics;
|
||
using System.Collections.Concurrent;
|
||
using System.Globalization;
|
||
using System.Net;
|
||
using System.Net.Http.Headers;
|
||
using System.Text;
|
||
using System.Text.Encodings.Web;
|
||
using System.Text.Json;
|
||
using System.Text.Json.Nodes;
|
||
using System.Text.Json.Serialization;
|
||
using System.Text.RegularExpressions;
|
||
|
||
static class MechanicalDiagnosticApi
|
||
{
|
||
public static IServiceCollection AddMechanicalDiagnostics(this IServiceCollection services)
|
||
{
|
||
services.AddSingleton<MechanicalDiagnosticService>();
|
||
return services;
|
||
}
|
||
|
||
public static WebApplication MapMechanicalDiagnosticApi(this WebApplication app)
|
||
{
|
||
var group = app.MapGroup("/api/mechanical-diagnostics");
|
||
|
||
group.MapGet("/usage", (MechanicalDiagnosticService diagnostics) =>
|
||
{
|
||
return Results.Ok(diagnostics.GetUsage());
|
||
});
|
||
|
||
group.MapPost("/evidence", async (
|
||
MechanicalEvidenceRequest request,
|
||
MechanicalDiagnosticService diagnostics) =>
|
||
{
|
||
try
|
||
{
|
||
return Results.Ok(await diagnostics.ExtractEvidenceAsync(request));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return Results.Problem($"机械查错证据提取失败:{ex.Message}", statusCode: 500);
|
||
}
|
||
});
|
||
|
||
group.MapPost("/bridge", async (
|
||
MechanicalBridgeRequest request,
|
||
MechanicalDiagnosticService diagnostics) =>
|
||
{
|
||
try
|
||
{
|
||
return Results.Ok(await diagnostics.BuildSemanticBridgeAsync(request));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return Results.Problem($"机械查错语义桥接失败:{ex.Message}", statusCode: 500);
|
||
}
|
||
});
|
||
|
||
group.MapPost("/interfaces/finalize-existing", async (
|
||
MechanicalInterfaceFinalizeRequest request,
|
||
MechanicalDiagnosticService diagnostics) =>
|
||
{
|
||
try
|
||
{
|
||
return Results.Ok(await diagnostics.FinalizeInterfaceDescriptionsExistingAsync(request));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return Results.Problem($"接触面功能语义分阶段生成失败:{ex.Message}", statusCode: 500);
|
||
}
|
||
});
|
||
|
||
group.MapPost("/diagnose", async (
|
||
MechanicalAiDiagnosisRequest request,
|
||
MechanicalDiagnosticService diagnostics) =>
|
||
{
|
||
try
|
||
{
|
||
return Results.Ok(await diagnostics.RunAiDiagnosisAsync(request));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return Results.Problem($"机械查错 AI 诊断失败:{ex.Message}", statusCode: 500);
|
||
}
|
||
});
|
||
|
||
group.MapPost("/diagnose/finalize-existing", async (
|
||
MechanicalExistingDiagnosisRequest request,
|
||
MechanicalDiagnosticService diagnostics) =>
|
||
{
|
||
try
|
||
{
|
||
return Results.Ok(await diagnostics.RunFinalDiagnosisFromExistingAsync(request));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return Results.Problem($"复用已有证据进行最终判定失败:{ex.Message}", statusCode: 500);
|
||
}
|
||
});
|
||
|
||
group.MapPost("/diagnose/load-existing", (
|
||
MechanicalExistingDiagnosisRequest request,
|
||
MechanicalDiagnosticService diagnostics) =>
|
||
{
|
||
try
|
||
{
|
||
return Results.Ok(diagnostics.LoadExistingDiagnosisResult(request));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return Results.Problem($"读取已有机械查错结果失败:{ex.Message}", statusCode: 500);
|
||
}
|
||
});
|
||
|
||
group.MapPost("/diagnose/retrieve-existing", (
|
||
MechanicalRetrievalOnlyRequest request,
|
||
MechanicalDiagnosticService diagnostics) =>
|
||
{
|
||
try
|
||
{
|
||
return Results.Ok(diagnostics.RunRetrievalFromExisting(request));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return Results.Problem($"复用已有局部子图进行规则检索失败:{ex.Message}", statusCode: 500);
|
||
}
|
||
});
|
||
|
||
group.MapPost("/diagnose/start", (
|
||
MechanicalAiDiagnosisRequest request,
|
||
MechanicalDiagnosticService diagnostics) =>
|
||
{
|
||
try
|
||
{
|
||
return Results.Ok(diagnostics.StartAiDiagnosis(request));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return Results.Problem($"机械查错任务启动失败:{ex.Message}", statusCode: 500);
|
||
}
|
||
});
|
||
|
||
group.MapGet("/diagnose/status/{jobId}", (string jobId, MechanicalDiagnosticService diagnostics) =>
|
||
{
|
||
var status = diagnostics.GetAiDiagnosisStatus(jobId);
|
||
return status == null ? Results.NotFound() : Results.Ok(status);
|
||
});
|
||
|
||
group.MapGet("/image", (string path, MechanicalDiagnosticService diagnostics) =>
|
||
{
|
||
var image = diagnostics.ResolveEvidenceImage(path);
|
||
return Results.File(image.FilePath, image.ContentType);
|
||
});
|
||
|
||
return app;
|
||
}
|
||
}
|
||
|
||
sealed partial class MechanicalDiagnosticService
|
||
{
|
||
readonly AppPaths _paths;
|
||
readonly IConfiguration _configuration;
|
||
readonly ConcurrentDictionary<string, MechanicalDiagnosisJobStatus> _diagnosisJobs = new(StringComparer.OrdinalIgnoreCase);
|
||
MechanicalOpenAiSettings OpenAi => MechanicalOpenAiSettings.From(_configuration);
|
||
|
||
public MechanicalDiagnosticService(AppPaths paths, IConfiguration configuration)
|
||
{
|
||
_paths = paths;
|
||
_configuration = configuration;
|
||
}
|
||
|
||
public MechanicalDiagnosticUsage GetUsage()
|
||
{
|
||
var openAi = OpenAi;
|
||
var knowledgeFiles = Directory.Exists(_paths.MechanicalKnowledgeIndexRoot)
|
||
? Directory.EnumerateFiles(_paths.MechanicalKnowledgeIndexRoot)
|
||
.Select(Path.GetFileName)
|
||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||
.ToList()!
|
||
: new List<string>();
|
||
|
||
return new MechanicalDiagnosticUsage
|
||
{
|
||
FlowBoundaryDocPath = _paths.DiagnosticFlowBoundariesDoc,
|
||
FlowBoundaryMarkdown = File.Exists(_paths.DiagnosticFlowBoundariesDoc)
|
||
? File.ReadAllText(_paths.DiagnosticFlowBoundariesDoc)
|
||
: "",
|
||
ContractDocPath = _paths.MechanicalDiagnosticContractDoc,
|
||
ContractMarkdown = File.Exists(_paths.MechanicalDiagnosticContractDoc)
|
||
? File.ReadAllText(_paths.MechanicalDiagnosticContractDoc)
|
||
: "",
|
||
UsageDocPath = _paths.MechanicalDiagnosticUsageDoc,
|
||
UsageMarkdown = File.Exists(_paths.MechanicalDiagnosticUsageDoc)
|
||
? File.ReadAllText(_paths.MechanicalDiagnosticUsageDoc)
|
||
: "",
|
||
KnowledgeIndexRoot = _paths.MechanicalKnowledgeIndexRoot,
|
||
KnowledgeFiles = knowledgeFiles,
|
||
SupportedStages =
|
||
[
|
||
"evidence",
|
||
"semantic_bridge",
|
||
"assembly_structure_summary",
|
||
"physical_interface_images",
|
||
"interface_function_descriptions",
|
||
"functional_blocks",
|
||
"inspection_cards",
|
||
"knowledge_retrieval",
|
||
"ai_final_judgement"
|
||
],
|
||
AiConfigured = openAi.HasApiKey,
|
||
AiModel = openAi.ResolveModel(null),
|
||
AiTextModel = openAi.ResolveTextModel(null),
|
||
AiBaseUrl = openAi.BaseUrl,
|
||
AiApiMode = openAi.ApiMode,
|
||
BackendRole = "C# backend orchestrates SolidWorks evidence extraction, per-component AI local subgraph generation, knowledge retrieval, and AI final diagnostic writing."
|
||
};
|
||
}
|
||
|
||
public async Task<MechanicalDiagnosticRunResult> ExtractEvidenceAsync(MechanicalEvidenceRequest request)
|
||
{
|
||
var modelPath = RequireModelPath(request.ModelPath);
|
||
var outputDir = PrepareOutputDir(request.OutputDir, "section_brep", modelPath);
|
||
var args = new List<string>
|
||
{
|
||
modelPath,
|
||
"--output-dir",
|
||
outputDir
|
||
};
|
||
if (request.ExportImages)
|
||
args.Add("--export-images");
|
||
|
||
var command = await RunDotnetProjectAsync(_paths.SectionBrepExtractorProject, args);
|
||
return BuildRunResult("evidence", modelPath, outputDir, command, new[]
|
||
{
|
||
"section_brep_report.json",
|
||
"section_brep_report.md"
|
||
});
|
||
}
|
||
|
||
public async Task<MechanicalDiagnosticRunResult> BuildSemanticBridgeAsync(MechanicalBridgeRequest request) =>
|
||
await BuildSemanticBridgeAsync(request, null);
|
||
|
||
public async Task<MechanicalDiagnosticRunResult> FinalizeInterfaceDescriptionsExistingAsync(MechanicalInterfaceFinalizeRequest request)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(request.OutputDir))
|
||
throw new InvalidOperationException("output_dir is required.");
|
||
|
||
var outputDir = Path.GetFullPath(request.OutputDir.Trim().Trim('"'));
|
||
if (!Directory.Exists(outputDir))
|
||
throw new DirectoryNotFoundException($"output directory not found: {outputDir}");
|
||
|
||
var structureGraphPath = Path.Combine(outputDir, "structure_graph.json");
|
||
if (!File.Exists(structureGraphPath))
|
||
throw new FileNotFoundException($"structure_graph.json not found: {structureGraphPath}");
|
||
|
||
var modelPath = request.ModelPath ?? "";
|
||
if (string.IsNullOrWhiteSpace(modelPath))
|
||
{
|
||
using var graph = JsonDocument.Parse(File.ReadAllText(structureGraphPath));
|
||
modelPath = FirstNonEmpty(FindString(graph.RootElement, "model_path", "modelPath", "path"), "");
|
||
}
|
||
|
||
var stage = string.IsNullOrWhiteSpace(request.Stage) ? "all" : request.Stage.Trim();
|
||
var args = new List<string>
|
||
{
|
||
"finalize-interfaces-existing",
|
||
outputDir,
|
||
"--stage",
|
||
stage
|
||
};
|
||
if (!string.IsNullOrWhiteSpace(request.Model))
|
||
{
|
||
args.Add("--model");
|
||
args.Add(request.Model.Trim());
|
||
}
|
||
if (request.MaxImages > 0)
|
||
{
|
||
args.Add("--max-images");
|
||
args.Add(request.MaxImages.ToString(CultureInfo.InvariantCulture));
|
||
}
|
||
|
||
var command = await RunDotnetProjectAsync(_paths.BrepFaultProbeProject, args);
|
||
return BuildRunResult($"interface_finalize_{stage}", modelPath, outputDir, command, new[]
|
||
{
|
||
"interface_finalize_existing_summary.json",
|
||
"diagnostic_context/interface_anchor_analysis.json",
|
||
"diagnostic_context/interface_anchor_analysis_prompt.md",
|
||
"diagnostic_context/interface_anchor_analysis_response.json",
|
||
"diagnostic_context/functional_group_descriptions.json",
|
||
"diagnostic_context/functional_group_descriptions.md",
|
||
"diagnostic_context/functional_group_descriptions_prompt.md",
|
||
"diagnostic_context/functional_group_descriptions_response.json",
|
||
"diagnostic_context/interface_function_descriptions.json",
|
||
"diagnostic_context/interface_function_descriptions.md"
|
||
});
|
||
}
|
||
|
||
async Task<MechanicalDiagnosticRunResult> BuildSemanticBridgeAsync(MechanicalBridgeRequest request, MechanicalDiagnosisJobStatus? job)
|
||
{
|
||
var modelPath = RequireModelPath(request.ModelPath);
|
||
if (Path.GetExtension(modelPath).Equals(".SLDPRT", StringComparison.OrdinalIgnoreCase) &&
|
||
string.IsNullOrWhiteSpace(request.PartFunctionProfile))
|
||
{
|
||
throw new InvalidOperationException("单零件查错需要填写零件功能画像。");
|
||
}
|
||
var outputDir = PrepareOutputDir(request.OutputDir, "brep_semantic_bridge", modelPath);
|
||
var args = new List<string>
|
||
{
|
||
"bridge",
|
||
modelPath,
|
||
outputDir,
|
||
"--max-iterations",
|
||
Math.Max(1, request.MaxIterations).ToString()
|
||
};
|
||
if (!string.IsNullOrWhiteSpace(request.Model))
|
||
{
|
||
args.Add("--model");
|
||
args.Add(request.Model.Trim());
|
||
}
|
||
if (request.Deep)
|
||
args.Add("--deep");
|
||
if (request.NoAi)
|
||
args.Add("--no-ai");
|
||
if (!string.IsNullOrWhiteSpace(request.ProductDescription))
|
||
{
|
||
args.Add("--product-description");
|
||
args.Add(request.ProductDescription.Trim());
|
||
}
|
||
if (!string.IsNullOrWhiteSpace(request.PartFunctionProfile))
|
||
{
|
||
args.Add("--part-function-profile");
|
||
args.Add(request.PartFunctionProfile.Trim());
|
||
}
|
||
foreach (var hint in request.PurchasedComponentHints.Where(hint => !string.IsNullOrWhiteSpace(hint)))
|
||
{
|
||
args.Add("--purchased-component-hint");
|
||
args.Add(hint.Trim());
|
||
}
|
||
|
||
var command = await RunDotnetProjectAsync(
|
||
_paths.BrepFaultProbeProject,
|
||
args,
|
||
job == null ? null : token => MonitorSemanticBridgeProgressAsync(outputDir, job, token));
|
||
return BuildRunResult("semantic_bridge", modelPath, outputDir, command, new[]
|
||
{
|
||
"semantic_bridge_manifest.json",
|
||
"semantic_bridge_package.json",
|
||
"structure_graph.json",
|
||
"mechanical_semantic_graph_contract.json",
|
||
"prompt.md",
|
||
"self_evolution_audit.json",
|
||
"part_check_extractions/manifest.json",
|
||
"component_work_queue.json",
|
||
"diagnostic_context/assembly_structure_summary.json",
|
||
"diagnostic_context/functional_blocks.json",
|
||
"diagnostic_context/part_function_profiles.json",
|
||
"diagnostic_context/component_function_introductions.json",
|
||
"diagnostic_context/component_function_introductions.md",
|
||
"diagnostic_context/functional_group_relationships.json",
|
||
"diagnostic_context/functional_group_relationships.md",
|
||
"functional_group_evidence/manifest.json",
|
||
"diagnostic_context/interface_anchor_analysis.json",
|
||
"diagnostic_context/interface_function_descriptions.json",
|
||
"diagnostic_context/interface_function_descriptions.md",
|
||
"diagnostic_context/functional_group_descriptions.json",
|
||
"diagnostic_context/functional_group_descriptions.md",
|
||
"diagnostic_context/static_scout_rules_part_knowledge.md",
|
||
"diagnostic_context/static_scout_rules_assembly_knowledge.md",
|
||
"diagnostic_context/inspection_cards_part_knowledge.json",
|
||
"diagnostic_context/inspection_cards_assembly_knowledge.json",
|
||
"local_subgraph_collection.json",
|
||
"mechanical_semantic_graph.json",
|
||
"ai_interpretation.md"
|
||
});
|
||
}
|
||
|
||
public MechanicalDiagnosisJobStatus StartAiDiagnosis(MechanicalAiDiagnosisRequest request)
|
||
{
|
||
var job = new MechanicalDiagnosisJobStatus
|
||
{
|
||
JobId = Guid.NewGuid().ToString("N"),
|
||
Status = "running",
|
||
Phase = "queued",
|
||
Message = "任务已创建,等待后端开始处理。",
|
||
ModelPath = request.ModelPath,
|
||
StartedAt = DateTimeOffset.Now,
|
||
UpdatedAt = DateTimeOffset.Now
|
||
};
|
||
_diagnosisJobs[job.JobId] = job;
|
||
|
||
_ = Task.Run(async () =>
|
||
{
|
||
try
|
||
{
|
||
var result = await RunAiDiagnosisAsync(request, job);
|
||
job.Result = result;
|
||
job.RetrievedIssues = result.RetrievedIssues;
|
||
job.ProcessedUnits = job.TotalUnits;
|
||
UpdateJob(job, status: result.Ok ? "done" : "failed", phase: "completed", message: result.FinalMessage);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
UpdateJob(job, status: "failed", phase: "failed", message: ex.Message);
|
||
job.Error = ex.ToString();
|
||
}
|
||
});
|
||
|
||
return job;
|
||
}
|
||
|
||
public MechanicalDiagnosisJobStatus? GetAiDiagnosisStatus(string jobId)
|
||
{
|
||
return _diagnosisJobs.TryGetValue(jobId, out var job) ? job : null;
|
||
}
|
||
|
||
public Task<MechanicalAiDiagnosisResult> RunAiDiagnosisAsync(MechanicalAiDiagnosisRequest request) =>
|
||
RunAiDiagnosisAsync(request, null);
|
||
|
||
public MechanicalAiDiagnosisResult LoadExistingDiagnosisResult(MechanicalExistingDiagnosisRequest request)
|
||
{
|
||
var outputDir = ResolveCompletedDiagnosisOutputDir(request.OutputDir);
|
||
var graphPath = FirstExistingPath(
|
||
Path.Combine(outputDir, "mechanical_semantic_graph.json"),
|
||
Path.Combine(outputDir, "extractions", "primary_y", "mechanical_semantic_graph.json"));
|
||
if (string.IsNullOrWhiteSpace(graphPath))
|
||
throw new FileNotFoundException($"mechanical_semantic_graph.json not found under: {outputDir}");
|
||
|
||
var retrievalPath = Path.Combine(outputDir, "knowledge_retrieval_candidates.json");
|
||
var finalReportPath = Path.Combine(outputDir, "final_ai_diagnostic_report.md");
|
||
var finalResponsePath = Path.Combine(outputDir, "final_ai_diagnostic_response.json");
|
||
if (!File.Exists(retrievalPath))
|
||
throw new FileNotFoundException($"knowledge_retrieval_candidates.json not found under: {outputDir}");
|
||
if (!File.Exists(finalReportPath))
|
||
throw new FileNotFoundException($"final_ai_diagnostic_report.md not found under: {outputDir}");
|
||
|
||
var retrieval = JsonSerializer.Deserialize<MechanicalKnowledgeRetrievalResult>(
|
||
File.ReadAllText(retrievalPath),
|
||
MechanicalJsonOptions.Default) ?? throw new InvalidDataException("Existing knowledge retrieval result is empty.");
|
||
using var graphDocument = JsonDocument.Parse(File.ReadAllText(graphPath));
|
||
var graph = graphDocument.RootElement;
|
||
var evidenceImages = CollectEvidenceImages(outputDir);
|
||
var evidenceImagesByUnit = BuildEvidenceImagesByUnit(retrieval, evidenceImages, OpenAi.MaxImages);
|
||
var retrievedIssues = BuildRetrievedIssues(retrieval, evidenceImagesByUnit);
|
||
|
||
var finalStatus = "ok";
|
||
if (File.Exists(finalResponsePath))
|
||
{
|
||
using var responseDocument = JsonDocument.Parse(File.ReadAllText(finalResponsePath));
|
||
finalStatus = FirstNonEmpty(ReadString(responseDocument.RootElement, "status"), finalStatus);
|
||
}
|
||
|
||
var bridgeArtifactNames = new[]
|
||
{
|
||
"semantic_bridge_manifest.json",
|
||
"semantic_bridge_package.json",
|
||
"structure_graph.json",
|
||
"mechanical_semantic_graph_contract.json",
|
||
"prompt.md",
|
||
"self_evolution_audit.json",
|
||
"part_check_extractions/manifest.json",
|
||
"component_work_queue.json",
|
||
"diagnostic_context/assembly_structure_summary.json",
|
||
"diagnostic_context/functional_blocks.json",
|
||
"diagnostic_context/part_function_profiles.json",
|
||
"diagnostic_context/component_function_introductions.json",
|
||
"diagnostic_context/component_function_introductions.md",
|
||
"diagnostic_context/functional_group_relationships.json",
|
||
"diagnostic_context/functional_group_relationships.md",
|
||
"functional_group_evidence/manifest.json",
|
||
"diagnostic_context/interface_anchor_analysis.json",
|
||
"diagnostic_context/interface_function_descriptions.json",
|
||
"diagnostic_context/interface_function_descriptions.md",
|
||
"diagnostic_context/functional_group_descriptions.json",
|
||
"diagnostic_context/functional_group_descriptions.md",
|
||
"diagnostic_context/static_scout_rules_part_knowledge.md",
|
||
"diagnostic_context/static_scout_rules_assembly_knowledge.md",
|
||
"diagnostic_context/inspection_cards_part_knowledge.json",
|
||
"diagnostic_context/inspection_cards_assembly_knowledge.json",
|
||
"local_subgraph_collection.json",
|
||
Path.GetRelativePath(outputDir, graphPath),
|
||
"ai_interpretation.md"
|
||
};
|
||
var resultArtifactNames = new[]
|
||
{
|
||
"knowledge_retrieval_candidates.json",
|
||
"knowledge_retrieval_reranked_candidates.json",
|
||
"knowledge_retrieval_selected_for_judgement.json",
|
||
"knowledge_retrieval_functional_group_candidates.json",
|
||
"knowledge_retrieval_group_internal_part_candidates.json",
|
||
"rule_judgement_inputs.json",
|
||
"final_ai_diagnostic_report.md",
|
||
"final_ai_diagnostic_response.json",
|
||
"final_judgement_functional_group/final_ai_diagnostic_report.md",
|
||
"final_judgement_functional_group/final_ai_diagnostic_response.json",
|
||
"final_judgement_group_internal_parts/final_ai_diagnostic_report.md",
|
||
"final_judgement_group_internal_parts/final_ai_diagnostic_response.json"
|
||
};
|
||
var bridgeArtifacts = bridgeArtifactNames
|
||
.Select(name => MechanicalArtifact.From(outputDir, name))
|
||
.Where(artifact => artifact.Exists)
|
||
.ToList();
|
||
var artifacts = bridgeArtifacts
|
||
.Concat(resultArtifactNames.Select(name => MechanicalArtifact.From(outputDir, name)))
|
||
.Where(artifact => artifact.Exists)
|
||
.DistinctBy(artifact => artifact.FilePath, StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
var modelPath = FirstNonEmpty(request.ModelPath, FindString(graph, "model_path", "modelPath", "path"));
|
||
|
||
return new MechanicalAiDiagnosisResult
|
||
{
|
||
Ok = finalStatus.Equals("ok", StringComparison.OrdinalIgnoreCase),
|
||
ModelPath = modelPath,
|
||
OutputDir = outputDir,
|
||
Bridge = new MechanicalDiagnosticRunResult
|
||
{
|
||
Ok = true,
|
||
Stage = "load_existing_completed_result",
|
||
ModelPath = modelPath,
|
||
OutputDir = outputDir,
|
||
Artifacts = bridgeArtifacts,
|
||
NextHint = "Loaded the completed diagnosis without rerunning SolidWorks extraction, retrieval, or AI judgement."
|
||
},
|
||
SemanticGraphPath = graphPath,
|
||
RetrievalPath = retrievalPath,
|
||
FinalReportPath = finalReportPath,
|
||
FinalResponsePath = finalResponsePath,
|
||
FinalStatus = finalStatus,
|
||
FinalMessage = "已读取已有完整诊断结果,未重新运行 SolidWorks、知识检索或 AI 判定。",
|
||
FinalReportMarkdown = File.ReadAllText(finalReportPath),
|
||
RetrievedCandidateCount = retrieval.Units
|
||
.SelectMany(unit => unit.Candidates)
|
||
.Select(candidate => FirstNonEmpty(candidate.RuleId, candidate.Code))
|
||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.Count(),
|
||
RetrievedIssues = retrievedIssues,
|
||
FinalIssues = BuildFinalIssues(File.ReadAllText(finalReportPath), retrievedIssues),
|
||
Artifacts = artifacts
|
||
};
|
||
}
|
||
|
||
string ResolveCompletedDiagnosisOutputDir(string? requestedOutputDir)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(requestedOutputDir))
|
||
{
|
||
var requested = Path.GetFullPath(requestedOutputDir.Trim().Trim('"'));
|
||
if (!Directory.Exists(requested))
|
||
throw new DirectoryNotFoundException($"output_dir not found: {requested}");
|
||
return requested;
|
||
}
|
||
|
||
var runtimeRoot = Path.GetFullPath(_paths.RuntimeDir);
|
||
var latest = Directory.EnumerateDirectories(runtimeRoot, "*", SearchOption.TopDirectoryOnly)
|
||
.Where(path => File.Exists(Path.Combine(path, "final_ai_diagnostic_report.md")))
|
||
.Where(path => File.Exists(Path.Combine(path, "knowledge_retrieval_candidates.json")))
|
||
.OrderByDescending(path => File.GetLastWriteTimeUtc(Path.Combine(path, "final_ai_diagnostic_report.md")))
|
||
.FirstOrDefault();
|
||
return latest ?? throw new DirectoryNotFoundException($"No completed mechanical diagnosis result found under: {runtimeRoot}");
|
||
}
|
||
|
||
public async Task<MechanicalAiDiagnosisResult> RunFinalDiagnosisFromExistingAsync(MechanicalExistingDiagnosisRequest request)
|
||
{
|
||
var outputDir = string.IsNullOrWhiteSpace(request.OutputDir)
|
||
? Path.Combine(_paths.RuntimeDir, "brep_semantic_bridge")
|
||
: Path.GetFullPath(request.OutputDir.Trim().Trim('"'));
|
||
if (!Directory.Exists(outputDir))
|
||
throw new DirectoryNotFoundException($"output_dir not found: {outputDir}");
|
||
|
||
var graphPath = FirstExistingPath(
|
||
Path.Combine(outputDir, "mechanical_semantic_graph.json"),
|
||
Path.Combine(outputDir, "extractions", "primary_y", "mechanical_semantic_graph.json"));
|
||
if (string.IsNullOrWhiteSpace(graphPath))
|
||
throw new FileNotFoundException($"mechanical_semantic_graph.json not found under: {outputDir}");
|
||
|
||
UpdateJob(null, phase: "validate_semantic_graph", message: "正在校验已有局部子图集合。");
|
||
using var graphDoc = JsonDocument.Parse(File.ReadAllText(graphPath));
|
||
var graph = graphDoc.RootElement.Clone();
|
||
if (request.ComponentIds.Count > 0)
|
||
graph = FilterSemanticGraphByComponents(graph, request.ComponentIds);
|
||
graph = NormalizeSemanticGraphForRetrieval(graph, outputDir);
|
||
File.WriteAllText(graphPath, graph.GetRawText(), new UTF8Encoding(false));
|
||
|
||
var contractIssues = ValidateSemanticGraphForRetrieval(graph);
|
||
if (contractIssues.Count > 0)
|
||
{
|
||
throw new InvalidOperationException(
|
||
"Existing local subgraph collection does not satisfy the retrieval contract. "
|
||
+ "Issues: " + string.Join("; ", contractIssues));
|
||
}
|
||
|
||
var retrieval = RetrieveKnowledgeCandidates(graph, request.MaxRulesPerUnit);
|
||
var retrievalPath = Path.Combine(outputDir, "knowledge_retrieval_candidates.json");
|
||
File.WriteAllText(retrievalPath, JsonSerializer.Serialize(retrieval, MechanicalJsonOptions.Default), new UTF8Encoding(false));
|
||
WriteKnowledgeRetrievalArtifacts(outputDir, retrieval);
|
||
WriteStagedKnowledgeRetrievalArtifacts(outputDir, retrieval);
|
||
|
||
var evidenceImages = CollectEvidenceImages(outputDir);
|
||
var evidenceImagesByUnit = BuildEvidenceImagesByUnit(retrieval, evidenceImages, OpenAi.MaxImages);
|
||
var allRetrievedIssues = BuildRetrievedIssues(retrieval, evidenceImagesByUnit);
|
||
WriteRuleJudgementInputs(outputDir, retrieval, evidenceImagesByUnit);
|
||
|
||
var final = await RunStagedFinalJudgementAsync(
|
||
OpenAi.ResolveModel(request.Model),
|
||
OpenAi.RequireApiKey(),
|
||
OpenAi.BaseUrl,
|
||
OpenAi.ApiMode,
|
||
OpenAi.Proxy,
|
||
graph.GetRawText(),
|
||
retrieval,
|
||
outputDir,
|
||
evidenceImagesByUnit,
|
||
OpenAi.MaxImages);
|
||
|
||
var artifacts = new[]
|
||
{
|
||
MechanicalArtifact.From(outputDir, Path.GetRelativePath(outputDir, graphPath)),
|
||
MechanicalArtifact.From(outputDir, "diagnostic_context/interface_anchor_analysis.json"),
|
||
MechanicalArtifact.From(outputDir, "diagnostic_context/interface_function_descriptions.json"),
|
||
MechanicalArtifact.From(outputDir, "diagnostic_context/interface_function_descriptions.md"),
|
||
MechanicalArtifact.From(outputDir, "diagnostic_context/functional_group_descriptions.json"),
|
||
MechanicalArtifact.From(outputDir, "diagnostic_context/functional_group_descriptions.md"),
|
||
MechanicalArtifact.From(outputDir, "knowledge_retrieval_candidates.json"),
|
||
MechanicalArtifact.From(outputDir, "knowledge_retrieval_reranked_candidates.json"),
|
||
MechanicalArtifact.From(outputDir, "knowledge_retrieval_selected_for_judgement.json"),
|
||
MechanicalArtifact.From(outputDir, "knowledge_retrieval_functional_group_candidates.json"),
|
||
MechanicalArtifact.From(outputDir, "knowledge_retrieval_group_internal_part_candidates.json"),
|
||
MechanicalArtifact.From(outputDir, "rule_judgement_inputs.json"),
|
||
MechanicalArtifact.From(outputDir, "final_ai_diagnostic_report.md"),
|
||
MechanicalArtifact.From(outputDir, "final_ai_diagnostic_response.json"),
|
||
MechanicalArtifact.From(outputDir, "final_judgement_functional_group/final_ai_diagnostic_report.md"),
|
||
MechanicalArtifact.From(outputDir, "final_judgement_functional_group/final_ai_diagnostic_response.json"),
|
||
MechanicalArtifact.From(outputDir, "final_judgement_group_internal_parts/final_ai_diagnostic_report.md"),
|
||
MechanicalArtifact.From(outputDir, "final_judgement_group_internal_parts/final_ai_diagnostic_response.json")
|
||
}
|
||
.Where(artifact => artifact.Exists)
|
||
.ToList();
|
||
|
||
var modelPath = FirstNonEmpty(request.ModelPath, FindString(graph, "model_path", "modelPath", "path"));
|
||
var finalReportMarkdown = File.Exists(final.FinalReportPath) ? File.ReadAllText(final.FinalReportPath) : final.Message;
|
||
return new MechanicalAiDiagnosisResult
|
||
{
|
||
Ok = final.Status == "ok",
|
||
ModelPath = modelPath,
|
||
OutputDir = outputDir,
|
||
Bridge = new MechanicalDiagnosticRunResult
|
||
{
|
||
Ok = true,
|
||
Stage = "reuse_existing_local_subgraphs",
|
||
ModelPath = modelPath,
|
||
OutputDir = outputDir,
|
||
Artifacts = artifacts,
|
||
NextHint = "Reused existing B-rep/images and local subgraph collection; reran retrieval and final judgement only."
|
||
},
|
||
SemanticGraphPath = graphPath,
|
||
RetrievalPath = retrievalPath,
|
||
FinalReportPath = final.FinalReportPath,
|
||
FinalResponsePath = final.RawResponsePath,
|
||
FinalStatus = final.Status,
|
||
FinalMessage = final.Message,
|
||
FinalReportMarkdown = finalReportMarkdown,
|
||
RetrievedCandidateCount = retrieval.Units.SelectMany(unit => unit.Candidates).Select(candidate => FirstNonEmpty(candidate.RuleId, candidate.Code)).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||
RetrievedIssues = allRetrievedIssues,
|
||
FinalIssues = BuildFinalIssues(finalReportMarkdown, allRetrievedIssues),
|
||
Artifacts = artifacts
|
||
};
|
||
}
|
||
|
||
public MechanicalRetrievalOnlyResult RunRetrievalFromExisting(MechanicalRetrievalOnlyRequest request)
|
||
{
|
||
var outputDir = string.IsNullOrWhiteSpace(request.OutputDir)
|
||
? Path.Combine(_paths.RuntimeDir, "brep_semantic_bridge")
|
||
: Path.GetFullPath(request.OutputDir.Trim().Trim('"'));
|
||
if (!Directory.Exists(outputDir))
|
||
throw new DirectoryNotFoundException($"output_dir not found: {outputDir}");
|
||
|
||
var graphPath = FirstExistingPath(
|
||
Path.Combine(outputDir, "mechanical_semantic_graph.json"),
|
||
Path.Combine(outputDir, "extractions", "primary_y", "mechanical_semantic_graph.json"));
|
||
if (string.IsNullOrWhiteSpace(graphPath))
|
||
throw new FileNotFoundException($"mechanical_semantic_graph.json not found under: {outputDir}");
|
||
|
||
using var graphDoc = JsonDocument.Parse(File.ReadAllText(graphPath));
|
||
var graph = graphDoc.RootElement.Clone();
|
||
if (request.ComponentIds.Count > 0)
|
||
graph = FilterSemanticGraphByComponents(graph, request.ComponentIds);
|
||
graph = NormalizeSemanticGraphForRetrieval(graph, outputDir);
|
||
File.WriteAllText(graphPath, graph.GetRawText(), new UTF8Encoding(false));
|
||
|
||
var contractIssues = ValidateSemanticGraphForRetrieval(graph);
|
||
var retrieval = RetrieveKnowledgeCandidates(graph, request.MaxRulesPerUnit);
|
||
var retrievalPath = Path.Combine(outputDir, "knowledge_retrieval_candidates.json");
|
||
File.WriteAllText(retrievalPath, JsonSerializer.Serialize(retrieval, MechanicalJsonOptions.Default), new UTF8Encoding(false));
|
||
WriteKnowledgeRetrievalArtifacts(outputDir, retrieval);
|
||
WriteStagedKnowledgeRetrievalArtifacts(outputDir, retrieval);
|
||
|
||
var evidenceImages = CollectEvidenceImages(outputDir);
|
||
var evidenceImagesByUnit = BuildEvidenceImagesByUnit(retrieval, evidenceImages, OpenAi.MaxImages);
|
||
var allRetrievedIssues = BuildRetrievedIssues(retrieval, evidenceImagesByUnit);
|
||
WriteRuleJudgementInputs(outputDir, retrieval, evidenceImagesByUnit);
|
||
|
||
var reportPath = Path.Combine(outputDir, "retrieval_only_report.md");
|
||
File.WriteAllText(reportPath, BuildRetrievalOnlyReport(retrieval, contractIssues, request.ComponentIds), new UTF8Encoding(false));
|
||
|
||
return new MechanicalRetrievalOnlyResult
|
||
{
|
||
Ok = true,
|
||
OutputDir = outputDir,
|
||
SemanticGraphPath = graphPath,
|
||
RetrievalPath = retrievalPath,
|
||
SelectedForJudgementPath = Path.Combine(outputDir, "knowledge_retrieval_selected_for_judgement.json"),
|
||
RuleJudgementInputsPath = Path.Combine(outputDir, "rule_judgement_inputs.json"),
|
||
ReportPath = reportPath,
|
||
UnitCount = retrieval.Units.Count,
|
||
RetrievedCandidateCount = retrieval.Units.SelectMany(unit => unit.Candidates).Select(candidate => FirstNonEmpty(candidate.RuleId, candidate.Code)).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||
ContractIssues = contractIssues,
|
||
RetrievedIssues = allRetrievedIssues
|
||
};
|
||
}
|
||
|
||
async Task<MechanicalAiDiagnosisResult> RunAiDiagnosisAsync(MechanicalAiDiagnosisRequest request, MechanicalDiagnosisJobStatus? job)
|
||
{
|
||
UpdateJob(job, phase: "extract_brep", message: "正在连接 SolidWorks,准备提取装配体 B-rep。");
|
||
var bridgeRequest = new MechanicalBridgeRequest
|
||
{
|
||
ModelPath = request.ModelPath,
|
||
OutputDir = request.OutputDir,
|
||
NoAi = false,
|
||
Deep = request.Deep,
|
||
MaxIterations = request.MaxIterations,
|
||
Model = request.Model,
|
||
ProductDescription = request.ProductDescription,
|
||
PartFunctionProfile = request.PartFunctionProfile,
|
||
PurchasedComponentHints = request.PurchasedComponentHints
|
||
};
|
||
|
||
var bridge = await BuildSemanticBridgeAsync(bridgeRequest, job);
|
||
if (job != null)
|
||
{
|
||
job.OutputDir = bridge.OutputDir;
|
||
job.Bridge = bridge;
|
||
}
|
||
|
||
var graphPath = Path.Combine(bridge.OutputDir, "mechanical_semantic_graph.json");
|
||
if (!File.Exists(graphPath))
|
||
throw new InvalidOperationException("AI did not produce mechanical_semantic_graph.json. Check OPENAI_API_KEY, model, and ai_interpretation.md.");
|
||
|
||
UpdateJob(job, phase: "validate_semantic_graph", message: "正在校验局部子图集合和检索单元。");
|
||
using var graphDoc = JsonDocument.Parse(File.ReadAllText(graphPath));
|
||
var graph = NormalizeSemanticGraphForRetrieval(graphDoc.RootElement, bridge.OutputDir);
|
||
File.WriteAllText(graphPath, graph.GetRawText(), new UTF8Encoding(false));
|
||
var contractIssues = ValidateSemanticGraphForRetrieval(graph);
|
||
if (contractIssues.Count > 0)
|
||
{
|
||
throw new InvalidOperationException(
|
||
"AI local subgraph collection does not satisfy the mechanical diagnostic retrieval contract. "
|
||
+ "Required: local_semantic_units with primary_view, retrieval_sentence, retrieval_chain, and fact_bundle. "
|
||
+ "Issues: " + string.Join("; ", contractIssues));
|
||
}
|
||
|
||
UpdateJob(job, phase: "retrieval", message: "正在用局部语义单元、弱结构信号和侦查卡线索回查已精修禁忌规则库。");
|
||
var retrieval = RetrieveKnowledgeCandidates(graph, request.MaxRulesPerUnit);
|
||
var retrievalPath = Path.Combine(bridge.OutputDir, "knowledge_retrieval_candidates.json");
|
||
File.WriteAllText(retrievalPath, JsonSerializer.Serialize(retrieval, MechanicalJsonOptions.Default), new UTF8Encoding(false));
|
||
WriteKnowledgeRetrievalArtifacts(bridge.OutputDir, retrieval);
|
||
var evidenceImages = CollectEvidenceImages(bridge.OutputDir);
|
||
var evidenceImagesByUnit = BuildEvidenceImagesByUnit(retrieval, evidenceImages, OpenAi.MaxImages);
|
||
var allRetrievedIssues = BuildRetrievedIssues(retrieval, evidenceImagesByUnit);
|
||
WriteRuleJudgementInputs(bridge.OutputDir, retrieval, evidenceImagesByUnit);
|
||
if (job != null)
|
||
{
|
||
job.TotalUnits = retrieval.Units.Count;
|
||
job.ProcessedUnits = 0;
|
||
job.RetrievedIssues = [];
|
||
job.RetrievedCandidateCount = retrieval.Units.SelectMany(unit => unit.Candidates).Select(candidate => FirstNonEmpty(candidate.RuleId, candidate.Code)).Distinct(StringComparer.OrdinalIgnoreCase).Count();
|
||
job.UpdatedAt = DateTimeOffset.Now;
|
||
}
|
||
|
||
var final = await RunStagedFinalJudgementAsync(
|
||
OpenAi.ResolveModel(request.Model),
|
||
OpenAi.RequireApiKey(),
|
||
OpenAi.BaseUrl,
|
||
OpenAi.ApiMode,
|
||
OpenAi.Proxy,
|
||
graph.GetRawText(),
|
||
retrieval,
|
||
bridge.OutputDir,
|
||
evidenceImagesByUnit,
|
||
OpenAi.MaxImages,
|
||
job == null ? null : (unit, completed, total) =>
|
||
{
|
||
job.CurrentUnit = MechanicalDiagnosisUnitProgress.From(unit);
|
||
job.ProcessedUnits = completed;
|
||
job.TotalUnits = total;
|
||
UpdateJob(job, phase: "ai_final_judgement", message: $"AI \u6b63\u5728\u5206\u6790\uff1a{job.CurrentUnit.DisplayName}");
|
||
},
|
||
job == null ? null : (unit, completed, total) =>
|
||
{
|
||
job.ProcessedUnits = completed;
|
||
job.TotalUnits = total;
|
||
var completedUnitKeys = retrieval.Units
|
||
.Take(completed)
|
||
.Select(item => item.UnitKey)
|
||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||
job.RetrievedIssues = allRetrievedIssues
|
||
.Where(issue => completedUnitKeys.Contains(issue.UnitKey))
|
||
.ToList();
|
||
UpdateJob(job, phase: "ai_final_judgement", message: $"\u5df2\u5b8c\u6210 {completed}/{total} \u4e2a\u5c40\u90e8\u5355\u5143\u7684 AI \u5224\u5b9a\u3002");
|
||
});
|
||
|
||
var finalReportMarkdown = File.Exists(final.FinalReportPath) ? File.ReadAllText(final.FinalReportPath) : final.Message;
|
||
return new MechanicalAiDiagnosisResult
|
||
{
|
||
Ok = final.Status == "ok",
|
||
ModelPath = bridge.ModelPath,
|
||
OutputDir = bridge.OutputDir,
|
||
Bridge = bridge,
|
||
SemanticGraphPath = graphPath,
|
||
RetrievalPath = retrievalPath,
|
||
FinalReportPath = final.FinalReportPath,
|
||
FinalResponsePath = final.RawResponsePath,
|
||
FinalStatus = final.Status,
|
||
FinalMessage = final.Message,
|
||
FinalReportMarkdown = finalReportMarkdown,
|
||
RetrievedCandidateCount = retrieval.Units.SelectMany(unit => unit.Candidates).Select(candidate => FirstNonEmpty(candidate.RuleId, candidate.Code)).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||
RetrievedIssues = allRetrievedIssues,
|
||
FinalIssues = BuildFinalIssues(finalReportMarkdown, allRetrievedIssues),
|
||
Artifacts = bridge.Artifacts
|
||
.Concat(new[]
|
||
{
|
||
MechanicalArtifact.From(bridge.OutputDir, "knowledge_retrieval_candidates.json"),
|
||
MechanicalArtifact.From(bridge.OutputDir, "knowledge_retrieval_reranked_candidates.json"),
|
||
MechanicalArtifact.From(bridge.OutputDir, "knowledge_retrieval_selected_for_judgement.json"),
|
||
MechanicalArtifact.From(bridge.OutputDir, "knowledge_retrieval_functional_group_candidates.json"),
|
||
MechanicalArtifact.From(bridge.OutputDir, "knowledge_retrieval_group_internal_part_candidates.json"),
|
||
MechanicalArtifact.From(bridge.OutputDir, "rule_judgement_inputs.json"),
|
||
MechanicalArtifact.From(bridge.OutputDir, "final_ai_diagnostic_report.md"),
|
||
MechanicalArtifact.From(bridge.OutputDir, "final_ai_diagnostic_response.json"),
|
||
MechanicalArtifact.From(bridge.OutputDir, "final_judgement_functional_group/final_ai_diagnostic_report.md"),
|
||
MechanicalArtifact.From(bridge.OutputDir, "final_judgement_functional_group/final_ai_diagnostic_response.json"),
|
||
MechanicalArtifact.From(bridge.OutputDir, "final_judgement_group_internal_parts/final_ai_diagnostic_report.md"),
|
||
MechanicalArtifact.From(bridge.OutputDir, "final_judgement_group_internal_parts/final_ai_diagnostic_response.json")
|
||
})
|
||
.Where(artifact => artifact.Exists)
|
||
.ToList()
|
||
};
|
||
}
|
||
|
||
async Task<MechanicalFinalJudgeResult> RunStagedFinalJudgementAsync(
|
||
string model,
|
||
string apiKey,
|
||
string baseUrl,
|
||
string apiMode,
|
||
string proxy,
|
||
string semanticGraphJson,
|
||
MechanicalKnowledgeRetrievalResult retrieval,
|
||
string outputDir,
|
||
IReadOnlyDictionary<string, List<MechanicalEvidenceImage>> evidenceImagesByUnit,
|
||
int maxImages,
|
||
Action<MechanicalRetrievedUnit, int, int>? onUnitStarted = null,
|
||
Action<MechanicalRetrievedUnit, int, int>? onUnitCompleted = null)
|
||
{
|
||
Directory.CreateDirectory(outputDir);
|
||
var functionalGroupUnits = retrieval.Units.Where(IsFunctionalGroupJudgementUnit).ToList();
|
||
var partUnits = retrieval.Units.Where(unit => !IsFunctionalGroupJudgementUnit(unit)).ToList();
|
||
var totalUnits = retrieval.Units.Count;
|
||
var status = "ok";
|
||
var messages = new List<string>();
|
||
var stageSummaries = new List<object>();
|
||
|
||
var functionalGroupResult = await RunOneFinalJudgementStageAsync(
|
||
model,
|
||
apiKey,
|
||
baseUrl,
|
||
apiMode,
|
||
proxy,
|
||
semanticGraphJson,
|
||
CloneRetrievalWithUnits(retrieval, functionalGroupUnits, MechanicalDiagnosticFlowTerms.KnowledgeScopeAssembly),
|
||
Path.Combine(outputDir, "final_judgement_functional_group"),
|
||
evidenceImagesByUnit,
|
||
maxImages,
|
||
offset: 0,
|
||
totalUnits,
|
||
onUnitStarted,
|
||
onUnitCompleted);
|
||
stageSummaries.Add(new
|
||
{
|
||
stage = MechanicalDiagnosticFlowTerms.StageFunctionalGroupCheck,
|
||
knowledge_scope = MechanicalDiagnosticFlowTerms.KnowledgeScopeAssembly,
|
||
unit_count = functionalGroupUnits.Count,
|
||
status = functionalGroupResult.Status,
|
||
report_path = functionalGroupResult.FinalReportPath,
|
||
raw_response_path = functionalGroupResult.RawResponsePath
|
||
});
|
||
if (functionalGroupResult.Status != "ok")
|
||
{
|
||
status = "error";
|
||
messages.Add($"functional_group_check: {functionalGroupResult.Message}");
|
||
}
|
||
|
||
var partResult = await RunOneFinalJudgementStageAsync(
|
||
model,
|
||
apiKey,
|
||
baseUrl,
|
||
apiMode,
|
||
proxy,
|
||
semanticGraphJson,
|
||
CloneRetrievalWithUnits(retrieval, partUnits, MechanicalDiagnosticFlowTerms.KnowledgeScopePart),
|
||
Path.Combine(outputDir, "final_judgement_group_internal_parts"),
|
||
evidenceImagesByUnit,
|
||
maxImages,
|
||
offset: functionalGroupUnits.Count,
|
||
totalUnits,
|
||
onUnitStarted,
|
||
onUnitCompleted);
|
||
stageSummaries.Add(new
|
||
{
|
||
stage = MechanicalDiagnosticFlowTerms.StageGroupInternalPartCheck,
|
||
knowledge_scope = MechanicalDiagnosticFlowTerms.KnowledgeScopePart,
|
||
unit_count = partUnits.Count,
|
||
status = partResult.Status,
|
||
report_path = partResult.FinalReportPath,
|
||
raw_response_path = partResult.RawResponsePath
|
||
});
|
||
if (partResult.Status != "ok")
|
||
{
|
||
status = "error";
|
||
messages.Add($"group_internal_part_check: {partResult.Message}");
|
||
}
|
||
|
||
var finalReportPath = Path.Combine(outputDir, "final_ai_diagnostic_report.md");
|
||
var finalRawPath = Path.Combine(outputDir, "final_ai_diagnostic_response.json");
|
||
File.WriteAllText(finalReportPath, BuildStagedFinalReport(functionalGroupResult, partResult), new UTF8Encoding(false));
|
||
File.WriteAllText(finalRawPath, JsonSerializer.Serialize(new
|
||
{
|
||
schema = "mechanical_staged_final_judgement_v1",
|
||
status,
|
||
sequence = new[]
|
||
{
|
||
MechanicalDiagnosticFlowTerms.StageFunctionalGroupCheck,
|
||
MechanicalDiagnosticFlowTerms.StageGroupInternalPartCheck
|
||
},
|
||
stages = stageSummaries
|
||
}, MechanicalJsonOptions.Default), new UTF8Encoding(false));
|
||
|
||
return new MechanicalFinalJudgeResult
|
||
{
|
||
Status = status,
|
||
Message = messages.Count == 0 ? "AI final diagnostic report generated in staged order." : string.Join("; ", messages),
|
||
FinalReportPath = finalReportPath,
|
||
RawResponsePath = finalRawPath
|
||
};
|
||
}
|
||
|
||
async Task<MechanicalFinalJudgeResult> RunOneFinalJudgementStageAsync(
|
||
string model,
|
||
string apiKey,
|
||
string baseUrl,
|
||
string apiMode,
|
||
string proxy,
|
||
string semanticGraphJson,
|
||
MechanicalKnowledgeRetrievalResult retrieval,
|
||
string stageOutputDir,
|
||
IReadOnlyDictionary<string, List<MechanicalEvidenceImage>> evidenceImagesByUnit,
|
||
int maxImages,
|
||
int offset,
|
||
int totalUnits,
|
||
Action<MechanicalRetrievedUnit, int, int>? onUnitStarted,
|
||
Action<MechanicalRetrievedUnit, int, int>? onUnitCompleted)
|
||
{
|
||
return await MechanicalFinalJudge.CallPerUnitAsync(
|
||
model,
|
||
apiKey,
|
||
baseUrl,
|
||
apiMode,
|
||
proxy,
|
||
semanticGraphJson,
|
||
retrieval,
|
||
stageOutputDir,
|
||
evidenceImagesByUnit,
|
||
maxImages,
|
||
onUnitStarted == null ? null : (unit, started, _) => onUnitStarted(unit, offset + started, totalUnits),
|
||
onUnitCompleted == null ? null : (unit, completed, _) => onUnitCompleted(unit, offset + completed, totalUnits));
|
||
}
|
||
|
||
static MechanicalKnowledgeRetrievalResult CloneRetrievalWithUnits(
|
||
MechanicalKnowledgeRetrievalResult source,
|
||
IReadOnlyList<MechanicalRetrievedUnit> units,
|
||
string knowledgeScope)
|
||
{
|
||
return new MechanicalKnowledgeRetrievalResult
|
||
{
|
||
Schema = source.Schema,
|
||
KnowledgeScope = knowledgeScope,
|
||
IndexPath = source.IndexPath,
|
||
StorePath = source.StorePath,
|
||
Units = units.ToList()
|
||
};
|
||
}
|
||
|
||
static bool IsFunctionalGroupJudgementUnit(MechanicalRetrievedUnit unit)
|
||
{
|
||
if (unit.DiagnosticStage.Equals(MechanicalDiagnosticFlowTerms.StageFunctionalGroupCheck, StringComparison.OrdinalIgnoreCase) ||
|
||
unit.KnowledgeScope.Equals(MechanicalDiagnosticFlowTerms.KnowledgeScopeAssembly, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
var anchorType = unit.AnchorType.ToLowerInvariant();
|
||
return anchorType is "functional_group" or "interface";
|
||
}
|
||
|
||
static string BuildStagedFinalReport(MechanicalFinalJudgeResult functionalGroupResult, MechanicalFinalJudgeResult partResult)
|
||
{
|
||
var sb = new StringBuilder();
|
||
sb.AppendLine("# 机械设计查错结果");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## 功能组设计判定");
|
||
sb.AppendLine();
|
||
sb.AppendLine(ReadStageReportBody(functionalGroupResult));
|
||
sb.AppendLine();
|
||
sb.AppendLine("## 功能组内零件判定");
|
||
sb.AppendLine();
|
||
sb.AppendLine(ReadStageReportBody(partResult));
|
||
return sb.ToString().TrimEnd() + Environment.NewLine;
|
||
}
|
||
|
||
static string ReadStageReportBody(MechanicalFinalJudgeResult result)
|
||
{
|
||
if (!File.Exists(result.FinalReportPath))
|
||
return result.Message;
|
||
|
||
var text = File.ReadAllText(result.FinalReportPath).Trim();
|
||
text = Regex.Replace(text, @"^#\s*机械设计查错结果\s*", "", RegexOptions.IgnoreCase).Trim();
|
||
return string.IsNullOrWhiteSpace(text) ? result.Message : text;
|
||
}
|
||
|
||
public MechanicalEvidenceImageFile ResolveEvidenceImage(string encodedPath)
|
||
{
|
||
var path = Encoding.UTF8.GetString(Convert.FromBase64String(encodedPath));
|
||
var fullPath = Path.GetFullPath(path);
|
||
var runtimeRoot = Path.GetFullPath(_paths.RuntimeDir);
|
||
if (!fullPath.StartsWith(runtimeRoot, StringComparison.OrdinalIgnoreCase))
|
||
throw new InvalidOperationException("image path is outside the runtime directory.");
|
||
if (!File.Exists(fullPath))
|
||
throw new FileNotFoundException($"image not found: {fullPath}");
|
||
|
||
return new MechanicalEvidenceImageFile
|
||
{
|
||
FilePath = fullPath,
|
||
ContentType = Path.GetExtension(fullPath).ToLowerInvariant() switch
|
||
{
|
||
".jpg" or ".jpeg" => "image/jpeg",
|
||
".png" => "image/png",
|
||
".webp" => "image/webp",
|
||
_ => "application/octet-stream"
|
||
}
|
||
};
|
||
}
|
||
|
||
string RequireModelPath(string modelPath)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(modelPath))
|
||
throw new InvalidOperationException("model_path is required.");
|
||
|
||
var full = Path.GetFullPath(modelPath.Trim().Trim('"'));
|
||
if (!File.Exists(full))
|
||
throw new FileNotFoundException($"model file not found: {full}");
|
||
|
||
var ext = Path.GetExtension(full).ToLowerInvariant();
|
||
if (ext is not ".sldasm" and not ".sldprt")
|
||
throw new InvalidOperationException($"unsupported model extension: {ext}");
|
||
|
||
return full;
|
||
}
|
||
|
||
string PrepareOutputDir(string? requested, string defaultRuntimeSubdir, string modelPath)
|
||
{
|
||
var outputDir = string.IsNullOrWhiteSpace(requested)
|
||
? Path.Combine(_paths.RuntimeDir, defaultRuntimeSubdir, SanitizeFileName(Path.GetFileNameWithoutExtension(modelPath)))
|
||
: Path.GetFullPath(requested.Trim().Trim('"'));
|
||
Directory.CreateDirectory(outputDir);
|
||
return outputDir;
|
||
}
|
||
|
||
async Task<MechanicalCommandResult> RunDotnetProjectAsync(
|
||
string projectPath,
|
||
IReadOnlyList<string> args,
|
||
Func<CancellationToken, Task>? monitor = null)
|
||
{
|
||
if (!File.Exists(projectPath))
|
||
throw new FileNotFoundException($"tool project not found: {projectPath}");
|
||
|
||
var outputDirArg = args.Count >= 3 ? args[2] : "";
|
||
if (!string.IsNullOrWhiteSpace(outputDirArg) && Path.IsPathFullyQualified(outputDirArg))
|
||
Directory.CreateDirectory(outputDirArg);
|
||
|
||
var startInfo = new ProcessStartInfo
|
||
{
|
||
FileName = "dotnet",
|
||
WorkingDirectory = _paths.Agent4Root,
|
||
UseShellExecute = false,
|
||
RedirectStandardOutput = true,
|
||
RedirectStandardError = true,
|
||
};
|
||
startInfo.ArgumentList.Add("run");
|
||
startInfo.ArgumentList.Add("--project");
|
||
startInfo.ArgumentList.Add(projectPath);
|
||
startInfo.ArgumentList.Add("-c");
|
||
startInfo.ArgumentList.Add("Release");
|
||
startInfo.ArgumentList.Add("--");
|
||
foreach (var arg in args)
|
||
startInfo.ArgumentList.Add(arg);
|
||
var openAi = OpenAi;
|
||
if (openAi.HasApiKey)
|
||
startInfo.Environment["OPENAI_API_KEY"] = openAi.ApiKey;
|
||
startInfo.Environment["OPENAI_MODEL"] = openAi.ResolveModel(null);
|
||
startInfo.Environment["OPENAI_TEXT_MODEL"] = openAi.ResolveTextModel(null);
|
||
startInfo.Environment["OPENAI_BASE_URL"] = openAi.BaseUrl;
|
||
startInfo.Environment["OPENAI_API_MODE"] = openAi.ApiMode;
|
||
startInfo.Environment["OPENAI_MAX_IMAGES"] = openAi.MaxImages.ToString();
|
||
if (!string.IsNullOrWhiteSpace(openAi.Proxy))
|
||
{
|
||
startInfo.Environment["HTTPS_PROXY"] = openAi.Proxy;
|
||
startInfo.Environment["HTTP_PROXY"] = openAi.Proxy;
|
||
startInfo.Environment["ALL_PROXY"] = openAi.Proxy;
|
||
}
|
||
|
||
var startedAt = DateTimeOffset.Now;
|
||
using var process = Process.Start(startInfo) ?? throw new InvalidOperationException($"Failed to start tool: {projectPath}");
|
||
using var monitorCancellation = new CancellationTokenSource();
|
||
var monitorTask = monitor?.Invoke(monitorCancellation.Token);
|
||
var stdoutTask = process.StandardOutput.ReadToEndAsync();
|
||
var stderrTask = process.StandardError.ReadToEndAsync();
|
||
await process.WaitForExitAsync();
|
||
monitorCancellation.Cancel();
|
||
if (monitorTask != null)
|
||
{
|
||
try
|
||
{
|
||
await monitorTask;
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
}
|
||
}
|
||
var completedAt = DateTimeOffset.Now;
|
||
|
||
var result = new MechanicalCommandResult
|
||
{
|
||
ProjectPath = projectPath,
|
||
Arguments = args.ToList(),
|
||
ExitCode = process.ExitCode,
|
||
StandardOutput = await stdoutTask,
|
||
StandardError = await stderrTask,
|
||
StartedAt = startedAt,
|
||
CompletedAt = completedAt
|
||
};
|
||
|
||
if (result.ExitCode != 0)
|
||
throw new InvalidOperationException($"{Path.GetFileNameWithoutExtension(projectPath)} failed. exit={result.ExitCode}\n{result.StandardOutput}\n{result.StandardError}");
|
||
|
||
return result;
|
||
}
|
||
|
||
async Task MonitorSemanticBridgeProgressAsync(string outputDir, MechanicalDiagnosisJobStatus job, CancellationToken token)
|
||
{
|
||
var startedAt = DateTimeOffset.Now;
|
||
var statusPath = Path.Combine(outputDir, "extractions", "primary_y", "run_status.json");
|
||
var promptPath = Path.Combine(outputDir, "prompt.md");
|
||
var localSubgraphProgressPath = Path.Combine(outputDir, "local_subgraph_progress.json");
|
||
var semanticGraphPath = Path.Combine(outputDir, "mechanical_semantic_graph.json");
|
||
|
||
while (!token.IsCancellationRequested)
|
||
{
|
||
try
|
||
{
|
||
if (FreshFileExists(semanticGraphPath, startedAt))
|
||
{
|
||
UpdateJob(job, phase: "semantic_bridge", message: "局部子图集合已生成,正在进入检索单元校验。");
|
||
}
|
||
else if (TryReadLocalSubgraphProgress(localSubgraphProgressPath, startedAt, out var componentProgress))
|
||
{
|
||
job.CurrentSubgraphComponent = componentProgress;
|
||
job.TotalSubgraphComponents = componentProgress.TotalComponents;
|
||
job.ProcessedSubgraphComponents = componentProgress.ProcessedComponents;
|
||
UpdateJob(job, phase: "semantic_bridge", message: $"正在结合功能画像和侦查卡生成零件局部子图:{componentProgress.DisplayName}({componentProgress.CurrentIndex}/{componentProgress.TotalComponents})");
|
||
}
|
||
else if (FreshFileExists(promptPath, startedAt) && !FreshFileExists(semanticGraphPath, startedAt))
|
||
{
|
||
UpdateJob(job, phase: "semantic_bridge", message: "正在生成全局结构摘要、功能上下文和侦查卡,并按零件生成局部子图。");
|
||
}
|
||
else if (TryReadExtractionStatus(statusPath, startedAt, out var stage, out var message))
|
||
{
|
||
var mapped = MapExtractionStageToDiagnosticPhase(stage);
|
||
var detail = FormatProgressDetail(stage, message);
|
||
UpdateJob(job, phase: mapped.Phase, message: string.IsNullOrWhiteSpace(detail) ? mapped.Message : $"{mapped.Message} {detail}");
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// Progress reporting must not interrupt the diagnostic run.
|
||
}
|
||
|
||
await Task.Delay(700, token);
|
||
}
|
||
}
|
||
|
||
static bool TryReadExtractionStatus(string statusPath, DateTimeOffset startedAt, out string stage, out string message)
|
||
{
|
||
stage = "";
|
||
message = "";
|
||
if (!FreshFileExists(statusPath, startedAt))
|
||
return false;
|
||
|
||
using var doc = JsonDocument.Parse(File.ReadAllText(statusPath));
|
||
var root = doc.RootElement;
|
||
stage = ReadString(root, "CurrentStage");
|
||
if (string.IsNullOrWhiteSpace(stage))
|
||
stage = ReadString(root, "current_stage");
|
||
|
||
if (stage.Equals("failed", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
message = FirstNonEmpty(ReadString(root, "Error"), ReadString(root, "error"));
|
||
if (!string.IsNullOrWhiteSpace(message))
|
||
message = message.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? message;
|
||
return true;
|
||
}
|
||
|
||
if (root.TryGetProperty("Stages", out var stages) && stages.ValueKind == JsonValueKind.Array)
|
||
{
|
||
var last = stages.EnumerateArray().LastOrDefault();
|
||
message = ReadString(last, "Message");
|
||
if (string.IsNullOrWhiteSpace(message))
|
||
message = ReadString(last, "message");
|
||
}
|
||
|
||
return !string.IsNullOrWhiteSpace(stage);
|
||
}
|
||
|
||
static bool TryReadLocalSubgraphProgress(string progressPath, DateTimeOffset startedAt, out MechanicalSubgraphComponentProgress progress)
|
||
{
|
||
progress = new MechanicalSubgraphComponentProgress();
|
||
if (!FreshFileExists(progressPath, startedAt))
|
||
return false;
|
||
|
||
using var doc = JsonDocument.Parse(File.ReadAllText(progressPath));
|
||
var root = doc.RootElement;
|
||
progress.TotalComponents = ReadInt(root, "total_components");
|
||
progress.ProcessedComponents = ReadInt(root, "processed_components");
|
||
progress.CurrentIndex = ReadInt(root, "current_index");
|
||
progress.Iteration = ReadInt(root, "iteration");
|
||
progress.Status = ReadString(root, "status");
|
||
progress.Message = ReadString(root, "message");
|
||
|
||
if (root.TryGetProperty("current_component", out var component) && component.ValueKind == JsonValueKind.Object)
|
||
{
|
||
progress.ComponentId = ReadString(component, "component_id");
|
||
progress.InstanceName = ReadString(component, "instance_name");
|
||
progress.DisplayName = FirstNonEmpty(ReadString(component, "display_name"), progress.InstanceName, progress.ComponentId);
|
||
progress.EvidencePath = ReadString(component, "evidence_path");
|
||
progress.FeatureCount = ReadInt(component, "feature_count");
|
||
progress.FaceCount = ReadInt(component, "face_count");
|
||
progress.ContactCount = ReadInt(component, "contact_count");
|
||
progress.MateCount = ReadInt(component, "mate_count");
|
||
progress.ImageCount = ReadInt(component, "image_count");
|
||
progress.AdjacentComponentIds = ReadStringArray(component, "adjacent_component_ids");
|
||
}
|
||
|
||
return !string.IsNullOrWhiteSpace(progress.ComponentId) || !string.IsNullOrWhiteSpace(progress.DisplayName);
|
||
}
|
||
|
||
static (string Phase, string Message) MapExtractionStageToDiagnosticPhase(string stage)
|
||
{
|
||
if (stage.StartsWith("image_", StringComparison.OrdinalIgnoreCase))
|
||
return ("export_images", "正在导出零件图、装配体高亮图和内部组件组图片。");
|
||
|
||
return stage switch
|
||
{
|
||
"connect_solidworks" or "open_doc" or "get_solidworks_utilities" or "enumerate_components" =>
|
||
("extract_brep", "正在提取装配体 B-rep。"),
|
||
"enumerate_component" =>
|
||
("extract_brep", "正在枚举装配体组件。"),
|
||
"enumerate_component_skipped_standard" =>
|
||
("extract_brep", "标准件/外购件已跳过详细 B-rep。"),
|
||
"enumerate_component_completed" =>
|
||
("extract_brep", "已完成当前非标件 B-rep 提取。"),
|
||
"enumerate_components_completed" =>
|
||
("classify_components", "正在标记零件、标准件和内部组件组。"),
|
||
"build_axis_groups" =>
|
||
("classify_components", "正在整理装配轴线和同轴关系。"),
|
||
"build_assembly_face_contacts" or "verify_trimmed_contact_pair" =>
|
||
("verify_contacts", "正在验证零件之间的有限接触面。"),
|
||
"verify_trimmed_contact_pair_failed" =>
|
||
("verify_contacts", "一个组件对的有限接触面验证失败,正在继续处理其他组件对。"),
|
||
"verify_trimmed_contacts_completed" =>
|
||
("verify_contacts", "有限接触面验证已完成。"),
|
||
"skip_section_workflow" or "write_report" =>
|
||
("classify_components", "正在整理组件证据并写入提取报告。"),
|
||
"export_model_images" =>
|
||
("export_images", "正在导出零件图、装配体高亮图和内部组件组图片。"),
|
||
"failed" =>
|
||
("failed", "B-rep 或有限接触面提取失败。"),
|
||
"completed" =>
|
||
("export_images", "B-rep 和图片证据已提取,等待 LLM 生成功能介绍和局部子图。"),
|
||
_ =>
|
||
("extract_brep", "正在提取装配体 B-rep。")
|
||
};
|
||
}
|
||
|
||
static string FormatProgressDetail(string stage, string message)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(message))
|
||
return "";
|
||
|
||
if (stage.StartsWith("image_", StringComparison.OrdinalIgnoreCase) &&
|
||
(message.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
|
||
message.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ||
|
||
message.EndsWith(".png", StringComparison.OrdinalIgnoreCase)))
|
||
{
|
||
var parent = Path.GetFileName(Path.GetDirectoryName(message) ?? "");
|
||
var file = Path.GetFileName(message);
|
||
return string.IsNullOrWhiteSpace(parent) ? file : $"{parent}/{file}";
|
||
}
|
||
|
||
return message;
|
||
}
|
||
|
||
static bool FreshFileExists(string path, DateTimeOffset startedAt)
|
||
{
|
||
var file = new FileInfo(path);
|
||
return file.Exists && file.LastWriteTime >= startedAt.LocalDateTime.AddSeconds(-2);
|
||
}
|
||
|
||
MechanicalDiagnosticRunResult BuildRunResult(string stage, string modelPath, string outputDir, MechanicalCommandResult command, IEnumerable<string> expectedRelativePaths)
|
||
{
|
||
var artifacts = expectedRelativePaths
|
||
.Select(relative => MechanicalArtifact.From(outputDir, relative))
|
||
.Where(artifact => artifact.Exists)
|
||
.ToList();
|
||
|
||
return new MechanicalDiagnosticRunResult
|
||
{
|
||
Ok = command.ExitCode == 0,
|
||
Stage = stage,
|
||
ModelPath = modelPath,
|
||
OutputDir = outputDir,
|
||
Command = command,
|
||
Artifacts = artifacts,
|
||
NextHint = stage switch
|
||
{
|
||
"evidence" => "下一步可运行 semantic_bridge,把 B-rep/图片证据包按零件交给 AI 生成局部子图集合。",
|
||
"semantic_bridge" => "下一步应回查知识库完整规则,并让 AI 对局部子图、侦查卡线索和规则证据撰写最终判定。",
|
||
_ => ""
|
||
}
|
||
};
|
||
}
|
||
|
||
static List<string> ValidateSemanticGraphForRetrieval(JsonElement semanticGraph)
|
||
{
|
||
var issues = new List<string>();
|
||
if (!semanticGraph.TryGetProperty("local_semantic_units", out var units) ||
|
||
units.ValueKind != JsonValueKind.Array ||
|
||
units.GetArrayLength() == 0)
|
||
{
|
||
issues.Add("local_semantic_units is missing or empty");
|
||
return issues;
|
||
}
|
||
|
||
var i = 0;
|
||
var dedupKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
var linkedSubgraphIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var unit in units.EnumerateArray())
|
||
{
|
||
i++;
|
||
var id = FirstNonEmpty(ReadString(unit, "local_id"), ReadString(unit, "id"), $"#{i}");
|
||
var linkedSubgraphId = ReadString(unit, "linked_subgraph_id");
|
||
var primaryView = ReadString(unit, "primary_view");
|
||
var retrievalSentence = ReadString(unit, "retrieval_sentence");
|
||
var dedupKey = ReadString(unit, "dedup_key");
|
||
var anchorType = ReadString(unit, "anchor_type");
|
||
if (!string.IsNullOrWhiteSpace(linkedSubgraphId))
|
||
linkedSubgraphIds.Add(linkedSubgraphId);
|
||
if (string.IsNullOrWhiteSpace(primaryView))
|
||
issues.Add($"{id}: primary_view is empty");
|
||
if (string.IsNullOrWhiteSpace(retrievalSentence))
|
||
issues.Add($"{id}: retrieval_sentence is empty");
|
||
if (string.IsNullOrWhiteSpace(anchorType))
|
||
issues.Add($"{id}: anchor_type is empty");
|
||
else if (!IsAllowedAnchorType(anchorType))
|
||
issues.Add($"{id}: anchor_type is invalid: {anchorType}");
|
||
if (string.IsNullOrWhiteSpace(ReadString(unit, "anchor_id")) && !HasFactBundleAnchor(unit))
|
||
issues.Add($"{id}: anchor_id or fact_bundle.anchor.id is required");
|
||
if (string.IsNullOrWhiteSpace(dedupKey))
|
||
issues.Add($"{id}: dedup_key is empty");
|
||
else if (!dedupKeys.Add(dedupKey))
|
||
issues.Add($"{id}: duplicate dedup_key '{dedupKey}'");
|
||
if (ContainsJudgementWords(retrievalSentence))
|
||
issues.Add($"{id}: retrieval_sentence contains judgement words");
|
||
if (ContainsTechnicalBrepReference(retrievalSentence))
|
||
issues.Add($"{id}: retrieval_sentence contains raw B-rep ids; use human-readable mechanical semantics instead");
|
||
if (!unit.TryGetProperty("retrieval_chain", out var retrievalChain) || retrievalChain.ValueKind != JsonValueKind.Object)
|
||
issues.Add($"{id}: retrieval_chain is missing");
|
||
else
|
||
{
|
||
if (string.IsNullOrWhiteSpace(ReadString(retrievalChain, "geometric_fact_for_retrieval")))
|
||
issues.Add($"{id}: retrieval_chain.geometric_fact_for_retrieval is empty");
|
||
if (string.IsNullOrWhiteSpace(ReadString(retrievalChain, "mechanical_role")))
|
||
issues.Add($"{id}: retrieval_chain.mechanical_role is empty");
|
||
if (string.IsNullOrWhiteSpace(ReadString(retrievalChain, "process_assembly_maintenance_semantics")))
|
||
issues.Add($"{id}: retrieval_chain.process_assembly_maintenance_semantics is empty");
|
||
}
|
||
if (!unit.TryGetProperty("fact_bundle", out var factBundle) || factBundle.ValueKind != JsonValueKind.Object)
|
||
issues.Add($"{id}: fact_bundle is missing");
|
||
var imageEvidenceUnavailable = ReadString(factBundle, "image_evidence_status")
|
||
.Equals("no_exported_interface_image_for_anchor", StringComparison.OrdinalIgnoreCase);
|
||
if (CountArray(unit, "image_evidence_names") == 0 &&
|
||
CountArray(factBundle, "image_evidence_names") == 0 &&
|
||
!imageEvidenceUnavailable)
|
||
issues.Add($"{id}: image_evidence_names is empty");
|
||
}
|
||
|
||
if (!semanticGraph.TryGetProperty("local_subgraphs", out var subgraphs) ||
|
||
subgraphs.ValueKind != JsonValueKind.Array ||
|
||
subgraphs.GetArrayLength() == 0)
|
||
{
|
||
issues.Add("local_subgraphs is missing or empty");
|
||
}
|
||
else
|
||
{
|
||
var subgraphIds = subgraphs.EnumerateArray()
|
||
.Select(item => ReadString(item, "id"))
|
||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var linkedSubgraphId in linkedSubgraphIds)
|
||
{
|
||
if (!subgraphIds.Contains(linkedSubgraphId))
|
||
issues.Add($"linked_subgraph_id '{linkedSubgraphId}' is referenced by local_semantic_units but missing from local_subgraphs");
|
||
}
|
||
}
|
||
|
||
AddSemanticCoverageIssues(semanticGraph, issues);
|
||
return issues;
|
||
}
|
||
|
||
static void AddSemanticCoverageIssues(JsonElement semanticGraph, List<string> issues)
|
||
{
|
||
var partDesignComponentCount = CountPartDesignRequiredComponents(semanticGraph);
|
||
var localUnitCount = CountArray(semanticGraph, "local_semantic_units");
|
||
if (partDesignComponentCount > 0)
|
||
{
|
||
var minimumExpectedUnits = partDesignComponentCount >= 8
|
||
? Math.Min(partDesignComponentCount, 12)
|
||
: partDesignComponentCount;
|
||
if (localUnitCount < minimumExpectedUnits)
|
||
{
|
||
issues.Add($"semantic coverage is too low: {localUnitCount} local_semantic_units for {partDesignComponentCount} part_design_required component instances; expected at least {minimumExpectedUnits} before feature/interface/group units");
|
||
}
|
||
}
|
||
|
||
if (!semanticGraph.TryGetProperty("semantic_coverage_audit", out var audit) || audit.ValueKind != JsonValueKind.Object)
|
||
{
|
||
issues.Add("semantic_coverage_audit is missing");
|
||
return;
|
||
}
|
||
|
||
if (partDesignComponentCount > 0)
|
||
{
|
||
var coveredParts = CountArray(audit, "covered_part_component_ids");
|
||
var uncoveredPartAnchors = CountUncoveredPartAnchors(audit);
|
||
if (coveredParts + uncoveredPartAnchors < partDesignComponentCount)
|
||
{
|
||
issues.Add($"semantic_coverage_audit does not account for all part_design_required components: covered={coveredParts}, uncovered_parts={uncoveredPartAnchors}, expected={partDesignComponentCount}");
|
||
}
|
||
}
|
||
|
||
if (!audit.TryGetProperty("local_unit_count_by_anchor_type", out var byAnchor) || byAnchor.ValueKind != JsonValueKind.Object)
|
||
{
|
||
issues.Add("semantic_coverage_audit.local_unit_count_by_anchor_type is missing");
|
||
}
|
||
else
|
||
{
|
||
var actual = CountLocalUnitsByAnchorType(semanticGraph);
|
||
foreach (var anchorType in new[] { "part", "feature", "interface", "functional_group" })
|
||
{
|
||
var declared = ReadInt(byAnchor, anchorType);
|
||
actual.TryGetValue(anchorType, out var actualCount);
|
||
if (declared != actualCount)
|
||
issues.Add($"semantic_coverage_audit.local_unit_count_by_anchor_type.{anchorType}={declared} does not match actual local_semantic_units count {actualCount}");
|
||
}
|
||
|
||
var candidateFunctionalGroups = CountArray(audit, "candidate_functional_group_ids");
|
||
if (candidateFunctionalGroups > 0 &&
|
||
actual.GetValueOrDefault("functional_group") + actual.GetValueOrDefault("interface") == 0)
|
||
{
|
||
issues.Add($"functional-group coverage is missing: {candidateFunctionalGroups} candidate functional groups exist, but no functional_group or interface local_semantic_units were generated");
|
||
}
|
||
}
|
||
}
|
||
|
||
static Dictionary<string, int> CountLocalUnitsByAnchorType(JsonElement semanticGraph)
|
||
{
|
||
var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||
if (!semanticGraph.TryGetProperty("local_semantic_units", out var units) || units.ValueKind != JsonValueKind.Array)
|
||
return counts;
|
||
|
||
foreach (var unit in units.EnumerateArray())
|
||
{
|
||
var anchorType = NormalizeAnchorType(FirstNonEmpty(ReadString(unit, "anchor_type"), InferAnchorType(unit)));
|
||
if (string.IsNullOrWhiteSpace(anchorType))
|
||
continue;
|
||
counts[anchorType] = counts.TryGetValue(anchorType, out var count) ? count + 1 : 1;
|
||
}
|
||
|
||
return counts;
|
||
}
|
||
|
||
static string InferAnchorType(JsonElement unit)
|
||
{
|
||
var text = $"{ReadString(unit, "local_id")} {ReadString(unit, "pattern_type")} {ReadString(unit, "dedup_key")}".ToLowerInvariant();
|
||
if (text.Contains("part")) return "part";
|
||
if (text.Contains("interface") || text.Contains("contact") || text.Contains("mate") || text.Contains("fit")) return "interface";
|
||
if (text.Contains("group") || text.Contains("functional")) return "functional_group";
|
||
if (text.Contains("feature") || text.Contains("hole") || text.Contains("boss") || text.Contains("slot")) return "feature";
|
||
return "";
|
||
}
|
||
|
||
static string NormalizeAnchorType(string value)
|
||
{
|
||
var text = value.Trim().ToLowerInvariant();
|
||
return text switch
|
||
{
|
||
"component" => "part",
|
||
"part" => "part",
|
||
"feature" => "feature",
|
||
"face_group" => "feature",
|
||
"relation" => "interface",
|
||
"interface" => "interface",
|
||
"functional_group" => "functional_group",
|
||
"group" => "functional_group",
|
||
_ => text
|
||
};
|
||
}
|
||
|
||
static bool IsAllowedAnchorType(string value)
|
||
{
|
||
var normalized = NormalizeAnchorType(value);
|
||
return normalized is "part" or "feature" or "interface" or "functional_group";
|
||
}
|
||
|
||
static bool HasFactBundleAnchor(JsonElement unit)
|
||
{
|
||
return unit.TryGetProperty("fact_bundle", out var factBundle) &&
|
||
factBundle.ValueKind == JsonValueKind.Object &&
|
||
factBundle.TryGetProperty("anchor", out var anchor) &&
|
||
anchor.ValueKind == JsonValueKind.Object &&
|
||
!string.IsNullOrWhiteSpace(ReadString(anchor, "id"));
|
||
}
|
||
|
||
static int ReadInt(JsonElement element, string property)
|
||
{
|
||
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(property, out var value))
|
||
return 0;
|
||
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number))
|
||
return number;
|
||
if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out var parsed))
|
||
return parsed;
|
||
return 0;
|
||
}
|
||
|
||
static int CountPartDesignRequiredComponents(JsonElement semanticGraph)
|
||
{
|
||
if (!semanticGraph.TryGetProperty("nodes", out var nodes) || nodes.ValueKind != JsonValueKind.Array)
|
||
return 0;
|
||
|
||
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var node in nodes.EnumerateArray())
|
||
{
|
||
if (!string.Equals(ReadString(node, "type"), "component", StringComparison.OrdinalIgnoreCase))
|
||
continue;
|
||
if (!string.Equals(ReadString(node, "review_scope"), "part_design_required", StringComparison.OrdinalIgnoreCase))
|
||
continue;
|
||
|
||
var id = FirstNonEmpty(ReadString(node, "id"), ReadString(node, "display_name"));
|
||
if (!string.IsNullOrWhiteSpace(id))
|
||
ids.Add(id);
|
||
}
|
||
|
||
return ids.Count;
|
||
}
|
||
|
||
static int CountUncoveredPartAnchors(JsonElement audit)
|
||
{
|
||
if (!audit.TryGetProperty("uncovered_candidate_anchors", out var anchors) || anchors.ValueKind != JsonValueKind.Array)
|
||
return 0;
|
||
|
||
var count = 0;
|
||
foreach (var anchor in anchors.EnumerateArray())
|
||
{
|
||
var anchorType = ReadString(anchor, "anchor_type");
|
||
if (anchorType.Equals("part", StringComparison.OrdinalIgnoreCase) ||
|
||
anchorType.Equals("component", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
count++;
|
||
}
|
||
}
|
||
|
||
return count;
|
||
}
|
||
|
||
static JsonElement FilterSemanticGraphByComponents(JsonElement graph, IReadOnlyCollection<string> componentIds)
|
||
{
|
||
var allowed = componentIds
|
||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||
if (allowed.Count == 0)
|
||
return graph.Clone();
|
||
|
||
var nodes = ReadArray(graph, "nodes")
|
||
.Where(node => allowed.Contains(FirstNonEmpty(ReadString(node, "id"), ReadString(node, "component_id"))))
|
||
.Select(node => node.Clone())
|
||
.ToList();
|
||
var units = ReadArray(graph, "local_semantic_units")
|
||
.Where(unit => allowed.Contains(ReadString(unit, "component_id")))
|
||
.Select(unit => unit.Clone())
|
||
.ToList();
|
||
var linkedSubgraphIds = units
|
||
.Select(unit => ReadString(unit, "linked_subgraph_id"))
|
||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||
var subgraphs = ReadArray(graph, "local_subgraphs")
|
||
.Where(subgraph =>
|
||
allowed.Contains(ReadString(subgraph, "component_id")) ||
|
||
linkedSubgraphIds.Contains(ReadString(subgraph, "id")))
|
||
.Select(subgraph => subgraph.Clone())
|
||
.ToList();
|
||
|
||
return JsonSerializer.SerializeToElement(new
|
||
{
|
||
schema = FirstNonEmpty(ReadString(graph, "schema"), "mechanical_semantic_graph_v1"),
|
||
generation_mode = FirstNonEmpty(ReadString(graph, "generation_mode"), "filtered_component_local_subgraphs"),
|
||
source_filter_component_ids = allowed.OrderBy(id => id, StringComparer.OrdinalIgnoreCase).ToList(),
|
||
nodes,
|
||
edges = Array.Empty<object>(),
|
||
measurements = Array.Empty<object>(),
|
||
semantic_coverage_audit = new
|
||
{
|
||
purpose = "Filtered graph for retrieval preview.",
|
||
covered_part_component_ids = allowed.OrderBy(id => id, StringComparer.OrdinalIgnoreCase).ToList(),
|
||
uncovered_candidate_anchors = Array.Empty<object>()
|
||
},
|
||
local_semantic_units = units,
|
||
local_subgraphs = subgraphs,
|
||
uncertain_roles = ReadArray(graph, "uncertain_roles"),
|
||
missing_facts = ReadArray(graph, "missing_facts"),
|
||
evidence_index = ReadArray(graph, "evidence_index")
|
||
}, MechanicalJsonOptions.Default);
|
||
}
|
||
|
||
static JsonElement NormalizeSemanticGraphForRetrieval(JsonElement graph, string? outputDir = null)
|
||
{
|
||
var root = JsonNode.Parse(graph.GetRawText()) as JsonObject;
|
||
if (root == null)
|
||
return graph.Clone();
|
||
|
||
var units = root["local_semantic_units"] as JsonArray;
|
||
if (units != null)
|
||
{
|
||
foreach (var unit in units.OfType<JsonObject>())
|
||
NormalizeGraphLocalUnit(unit);
|
||
EnrichGraphLocalUnitsWithEvidenceImages(units, outputDir);
|
||
EnsureUniqueGraphDedupKeys(units);
|
||
}
|
||
|
||
var subgraphs = root["local_subgraphs"] as JsonArray;
|
||
if (units != null)
|
||
{
|
||
subgraphs = BuildGraphSubgraphsForLocalUnits(units);
|
||
root["local_subgraphs"] = subgraphs;
|
||
}
|
||
|
||
root["semantic_coverage_audit"] = NormalizeCoverageAudit(root["semantic_coverage_audit"] as JsonObject, units);
|
||
using var doc = JsonDocument.Parse(root.ToJsonString(MechanicalJsonOptions.Default));
|
||
return doc.RootElement.Clone();
|
||
}
|
||
|
||
static void NormalizeGraphLocalUnit(JsonObject unit)
|
||
{
|
||
unit["anchor_type"] = NormalizeGraphAnchorType(
|
||
NodeString(unit, "anchor_type"),
|
||
NodeString(unit, "primary_view"),
|
||
NodeString(unit, "pattern_type"),
|
||
JsonNodeText(unit["retrieval_relations"]));
|
||
var anchorId = NodeString(unit, "anchor_id");
|
||
if (!string.IsNullOrWhiteSpace(anchorId))
|
||
unit["linked_subgraph_id"] = StableGraphSubgraphId(NodeString(unit, "component_id"), NodeString(unit, "anchor_type"), anchorId);
|
||
unit["retrieval_chain"] = NormalizeGraphRetrievalChain(unit);
|
||
unit["fact_bundle"] = NormalizeGraphFactBundle(unit);
|
||
unit["secondary_views"] ??= new JsonArray();
|
||
unit["retrieval_semantic_roles"] ??= new JsonArray();
|
||
unit["retrieval_fact_tags"] ??= new JsonArray();
|
||
unit["retrieval_relations"] ??= new JsonArray();
|
||
unit["image_evidence_names"] ??= new JsonArray();
|
||
if (string.IsNullOrWhiteSpace(NodeString(unit, "dedup_key")))
|
||
unit["dedup_key"] = StableGraphDedupKey(unit);
|
||
}
|
||
|
||
static void EnrichGraphLocalUnitsWithEvidenceImages(JsonArray units, string? outputDir)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(outputDir) || !Directory.Exists(outputDir))
|
||
return;
|
||
|
||
var images = CollectEvidenceImages(outputDir);
|
||
foreach (var unit in units.OfType<JsonObject>())
|
||
{
|
||
var fact = unit["fact_bundle"] as JsonObject ?? NormalizeGraphFactBundle(unit);
|
||
unit["fact_bundle"] = fact;
|
||
var unitImages = unit["image_evidence_names"] as JsonArray ?? new JsonArray();
|
||
var factImages = fact["image_evidence_names"] as JsonArray ?? new JsonArray();
|
||
if (unitImages.Count > 0 || factImages.Count > 0)
|
||
continue;
|
||
|
||
var evidenceRefs = GraphUnitEvidenceRefs(unit);
|
||
var anchorType = NodeString(unit, "anchor_type");
|
||
var diagnosticStage = NodeString(unit, "diagnostic_stage");
|
||
var assemblyUnit = anchorType.Equals("interface", StringComparison.OrdinalIgnoreCase) ||
|
||
anchorType.Equals("functional_group", StringComparison.OrdinalIgnoreCase) ||
|
||
diagnosticStage.Equals(MechanicalDiagnosticFlowTerms.StageFunctionalGroupCheck, StringComparison.OrdinalIgnoreCase);
|
||
var matched = images
|
||
.Where(image => !assemblyUnit || IsFunctionalGroupEvidenceImage(image))
|
||
.Where(image => image.BindingRefs.Any(evidenceRefs.Contains))
|
||
.OrderBy(image => EvidenceImagePriority(image.FilePath))
|
||
.ThenBy(image => image.Name, StringComparer.OrdinalIgnoreCase)
|
||
.DistinctBy(image => image.Name, StringComparer.OrdinalIgnoreCase)
|
||
.Take(4)
|
||
.Select(image => image.Name)
|
||
.ToList();
|
||
|
||
if (matched.Count > 0)
|
||
{
|
||
var names = StringJsonArray(matched);
|
||
unit["image_evidence_names"] = names;
|
||
fact["image_evidence_names"] = names.DeepClone();
|
||
fact.Remove("image_evidence_status");
|
||
}
|
||
else if (assemblyUnit && evidenceRefs.Count > 0)
|
||
{
|
||
// Ordinary mate/planar-contact units intentionally have no physical-interface image.
|
||
fact["image_evidence_status"] = "no_exported_interface_image_for_anchor";
|
||
}
|
||
}
|
||
}
|
||
|
||
static HashSet<string> GraphUnitEvidenceRefs(JsonObject unit)
|
||
{
|
||
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
AddGraphEvidenceRefs(unit["evidence_refs"], result);
|
||
AddGraphEvidenceRefs(unit["retrieval_relations"], result);
|
||
if (unit["fact_bundle"] is JsonObject fact)
|
||
AddGraphEvidenceRefs(fact["evidence_refs"], result);
|
||
return result;
|
||
}
|
||
|
||
static void AddGraphEvidenceRefs(JsonNode? node, HashSet<string> result)
|
||
{
|
||
if (node is JsonValue value && value.TryGetValue<string>(out var text) && !string.IsNullOrWhiteSpace(text))
|
||
{
|
||
result.Add(text.Trim());
|
||
return;
|
||
}
|
||
if (node is not JsonArray array)
|
||
return;
|
||
foreach (var item in array)
|
||
AddGraphEvidenceRefs(item, result);
|
||
}
|
||
|
||
static JsonArray StringJsonArray(IEnumerable<string> values)
|
||
{
|
||
var result = new JsonArray();
|
||
foreach (var value in values.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase))
|
||
result.Add(value);
|
||
return result;
|
||
}
|
||
|
||
static void EnsureUniqueGraphDedupKeys(JsonArray units)
|
||
{
|
||
var objects = units.OfType<JsonObject>().ToList();
|
||
foreach (var duplicateGroup in objects
|
||
.GroupBy(unit => NodeString(unit, "dedup_key"), StringComparer.OrdinalIgnoreCase)
|
||
.Where(group => string.IsNullOrWhiteSpace(group.Key) || group.Count() > 1))
|
||
{
|
||
foreach (var unit in duplicateGroup)
|
||
unit["dedup_key"] = StableGraphDedupKey(unit);
|
||
}
|
||
|
||
foreach (var duplicateGroup in objects
|
||
.GroupBy(unit => NodeString(unit, "dedup_key"), StringComparer.OrdinalIgnoreCase)
|
||
.Where(group => group.Count() > 1))
|
||
{
|
||
var index = 0;
|
||
foreach (var unit in duplicateGroup.OrderBy(unit => NodeString(unit, "local_id"), StringComparer.OrdinalIgnoreCase))
|
||
{
|
||
index++;
|
||
unit["dedup_key"] = $"{NodeString(unit, "dedup_key")}:{FirstNonEmpty(NodeString(unit, "local_id"), index.ToString(CultureInfo.InvariantCulture))}";
|
||
}
|
||
}
|
||
}
|
||
|
||
static string StableGraphDedupKey(JsonObject unit)
|
||
{
|
||
var evidenceSignature = string.Join("|", GraphUnitEvidenceRefs(unit).OrderBy(value => value, StringComparer.OrdinalIgnoreCase));
|
||
var signature = string.Join("|",
|
||
NodeString(unit, "anchor_type"),
|
||
NodeString(unit, "anchor_id"),
|
||
NodeString(unit, "primary_view"),
|
||
evidenceSignature,
|
||
NodeString(unit, "retrieval_sentence"));
|
||
var hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(signature)))
|
||
.ToLowerInvariant()[..12];
|
||
return $"{FirstNonEmpty(NodeString(unit, "anchor_type"), "unit")}:{FirstNonEmpty(NodeString(unit, "anchor_id"), NodeString(unit, "local_id"), "unknown")}:{hash}";
|
||
}
|
||
|
||
static string NormalizeGraphAnchorType(string anchorType, string primaryView, string patternType, string relationsText)
|
||
{
|
||
var text = $"{anchorType} {primaryView} {patternType} {relationsText}".ToLowerInvariant();
|
||
if (text.Contains("mate") || text.Contains("contact") || text.Contains("fit") || text.Contains("interface") || text.Contains("assembly_contact_fit"))
|
||
return "interface";
|
||
if (anchorType.Equals("component", StringComparison.OrdinalIgnoreCase) || anchorType.Equals("part", StringComparison.OrdinalIgnoreCase))
|
||
return "part";
|
||
if (anchorType.Equals("group", StringComparison.OrdinalIgnoreCase) || anchorType.Equals("functional_group", StringComparison.OrdinalIgnoreCase))
|
||
return "functional_group";
|
||
return "feature";
|
||
}
|
||
|
||
static JsonObject NormalizeGraphRetrievalChain(JsonObject unit)
|
||
{
|
||
var existing = unit["retrieval_chain"];
|
||
if (existing is JsonObject obj)
|
||
{
|
||
return new JsonObject
|
||
{
|
||
["geometric_fact_for_retrieval"] = FirstNonEmpty(NodeString(obj, "geometric_fact_for_retrieval"), NodeString(unit, "retrieval_sentence")),
|
||
["mechanical_role"] = FirstNonEmpty(NodeString(obj, "mechanical_role"), JoinNodeValues(unit["retrieval_semantic_roles"])),
|
||
["process_assembly_maintenance_semantics"] = FirstNonEmpty(NodeString(obj, "process_assembly_maintenance_semantics"), JoinNodeValues(unit["retrieval_fact_tags"]), JoinNodeValues(unit["retrieval_relations"]))
|
||
};
|
||
}
|
||
|
||
return new JsonObject
|
||
{
|
||
["geometric_fact_for_retrieval"] = FirstNonEmpty(JsonNodeText(existing), NodeString(unit, "retrieval_sentence")),
|
||
["mechanical_role"] = FirstNonEmpty(JoinNodeValues(unit["retrieval_semantic_roles"]), JsonNodeText(unit["inferred_functions"]), NodeString(unit, "pattern_type")),
|
||
["process_assembly_maintenance_semantics"] = FirstNonEmpty(JoinNodeValues(unit["retrieval_fact_tags"]), JoinNodeValues(unit["retrieval_relations"]), JsonNodeText(unit["inferred_attributes"]))
|
||
};
|
||
}
|
||
|
||
static JsonObject NormalizeGraphFactBundle(JsonObject unit)
|
||
{
|
||
var fact = unit["fact_bundle"] as JsonObject ?? new JsonObject();
|
||
fact["component_id"] = FirstNonEmpty(NodeString(fact, "component_id"), NodeString(unit, "component_id"));
|
||
fact["instance_name"] = FirstNonEmpty(NodeString(fact, "instance_name"), NodeString(unit, "instance_name"));
|
||
fact["review_scope"] = FirstNonEmpty(NodeString(fact, "review_scope"), "part_design_required");
|
||
fact["semantic_location"] = FirstNonEmpty(NodeString(fact, "semantic_location"), NodeString(unit, "anchor_id"));
|
||
if (fact["evidence_refs"] is not JsonArray evidenceRefs || evidenceRefs.Count == 0)
|
||
fact["evidence_refs"] = CloneNode(unit["evidence_refs"]) ?? new JsonArray();
|
||
fact["image_evidence_names"] ??= CloneNode(unit["image_evidence_names"]) ?? new JsonArray();
|
||
fact["neighbor_evidence_refs"] ??= new JsonArray();
|
||
fact["roles"] ??= CloneNode(unit["retrieval_semantic_roles"]) ?? new JsonArray();
|
||
fact["measurements"] ??= CloneNode(unit["normalized_measurements"]) ?? new JsonObject();
|
||
fact["uncertainties"] ??= new JsonArray();
|
||
return fact;
|
||
}
|
||
|
||
static JsonObject BuildGraphSubgraphFromUnit(JsonObject unit)
|
||
{
|
||
return new JsonObject
|
||
{
|
||
["id"] = FirstNonEmpty(NodeString(unit, "linked_subgraph_id"), $"sg_{NodeString(unit, "local_id")}"),
|
||
["component_id"] = NodeString(unit, "component_id"),
|
||
["pattern_type"] = NodeString(unit, "pattern_type"),
|
||
["primary_view"] = NodeString(unit, "primary_view"),
|
||
["retrieval_sentence"] = NodeString(unit, "retrieval_sentence"),
|
||
["roles"] = CloneNode(unit["retrieval_semantic_roles"]) ?? new JsonArray(),
|
||
["evidence"] = CloneNode(unit["fact_bundle"]) ?? new JsonObject(),
|
||
["confidence"] = 0.5,
|
||
["reason"] = "generated from normalized local_semantic_unit"
|
||
};
|
||
}
|
||
|
||
static JsonArray BuildGraphSubgraphsForLocalUnits(JsonArray units)
|
||
{
|
||
var subgraphs = new JsonArray();
|
||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var unit in units.OfType<JsonObject>())
|
||
{
|
||
var subgraphId = NodeString(unit, "linked_subgraph_id");
|
||
if (string.IsNullOrWhiteSpace(subgraphId) || !seen.Add(subgraphId))
|
||
continue;
|
||
subgraphs.Add(BuildGraphSubgraphFromUnit(unit));
|
||
}
|
||
return subgraphs;
|
||
}
|
||
|
||
static string StableGraphSubgraphId(string componentId, string anchorType, string anchorId)
|
||
{
|
||
var raw = $"{FirstNonEmpty(componentId, "component")}_{FirstNonEmpty(anchorType, "anchor")}_{anchorId}".ToLowerInvariant();
|
||
var stable = Regex.Replace(raw, @"[^a-z0-9_\-\u4e00-\u9fff]+", "_").Trim('_');
|
||
return $"sg_{FirstNonEmpty(stable, Guid.NewGuid().ToString("N")[..8])}";
|
||
}
|
||
|
||
static JsonObject NormalizeCoverageAudit(JsonObject? audit, JsonArray? units)
|
||
{
|
||
audit ??= new JsonObject();
|
||
var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||
if (units != null)
|
||
{
|
||
foreach (var unit in units.OfType<JsonObject>())
|
||
{
|
||
var anchor = NodeString(unit, "anchor_type");
|
||
if (string.IsNullOrWhiteSpace(anchor))
|
||
continue;
|
||
counts[anchor] = counts.GetValueOrDefault(anchor) + 1;
|
||
}
|
||
}
|
||
|
||
audit["local_unit_count_by_anchor_type"] = new JsonObject
|
||
{
|
||
["part"] = counts.GetValueOrDefault("part"),
|
||
["feature"] = counts.GetValueOrDefault("feature"),
|
||
["interface"] = counts.GetValueOrDefault("interface"),
|
||
["functional_group"] = counts.GetValueOrDefault("functional_group")
|
||
};
|
||
audit["uncovered_candidate_anchors"] ??= new JsonArray();
|
||
return audit;
|
||
}
|
||
|
||
static JsonNode? CloneNode(JsonNode? node) => node == null ? null : JsonNode.Parse(node.ToJsonString());
|
||
|
||
static string NodeString(JsonObject obj, string property)
|
||
{
|
||
return obj.TryGetPropertyValue(property, out var value) && value is JsonValue jsonValue && jsonValue.TryGetValue<string>(out var text)
|
||
? text
|
||
: "";
|
||
}
|
||
|
||
static string JoinNodeValues(JsonNode? node)
|
||
{
|
||
if (node is JsonArray array)
|
||
return string.Join(";", array.Select(JsonNodeText).Where(value => !string.IsNullOrWhiteSpace(value)));
|
||
return JsonNodeText(node);
|
||
}
|
||
|
||
static string JsonNodeText(JsonNode? node)
|
||
{
|
||
if (node == null)
|
||
return "";
|
||
if (node is JsonValue value && value.TryGetValue<string>(out var text))
|
||
return text;
|
||
if (node is JsonArray array)
|
||
return string.Join(";", array.Select(JsonNodeText).Where(value => !string.IsNullOrWhiteSpace(value)));
|
||
return node.ToJsonString(MechanicalJsonOptions.Default);
|
||
}
|
||
|
||
static string BuildRetrievalOnlyReport(
|
||
MechanicalKnowledgeRetrievalResult retrieval,
|
||
IReadOnlyList<string> contractIssues,
|
||
IReadOnlyList<string> componentIds)
|
||
{
|
||
var sb = new StringBuilder();
|
||
sb.AppendLine("# First Components Retrieval Preview");
|
||
sb.AppendLine();
|
||
if (componentIds.Count > 0)
|
||
sb.AppendLine($"Component filter: `{string.Join(", ", componentIds)}`");
|
||
sb.AppendLine($"Local semantic units: {retrieval.Units.Count}");
|
||
sb.AppendLine($"Distinct selected rules: {retrieval.Units.SelectMany(u => u.Candidates).Select(c => c.RuleId).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.OrdinalIgnoreCase).Count()}");
|
||
sb.AppendLine();
|
||
if (contractIssues.Count > 0)
|
||
{
|
||
sb.AppendLine("## Contract Issues");
|
||
foreach (var issue in contractIssues.Take(30))
|
||
sb.AppendLine($"- {issue}");
|
||
if (contractIssues.Count > 30)
|
||
sb.AppendLine($"- ... {contractIssues.Count - 30} more");
|
||
sb.AppendLine();
|
||
}
|
||
|
||
sb.AppendLine("## Selected Rule Candidates");
|
||
foreach (var unit in retrieval.Units)
|
||
{
|
||
sb.AppendLine();
|
||
sb.AppendLine($"### {FirstNonEmpty(unit.UnitKey, unit.LocalId)}");
|
||
if (!string.IsNullOrWhiteSpace(unit.ComponentId) || !string.IsNullOrWhiteSpace(unit.InstanceName))
|
||
sb.AppendLine($"- component: `{unit.ComponentId}` {unit.InstanceName}");
|
||
sb.AppendLine($"- local_id: `{unit.LocalId}`");
|
||
sb.AppendLine($"- primary_view: `{unit.PrimaryView}`");
|
||
sb.AppendLine($"- pattern_type: `{unit.PatternType}`");
|
||
sb.AppendLine($"- retrieval_sentence: {unit.RetrievalSentence}");
|
||
if (unit.Candidates.Count == 0)
|
||
{
|
||
sb.AppendLine("- selected_rules: none");
|
||
continue;
|
||
}
|
||
|
||
foreach (var candidate in unit.Candidates.Take(8))
|
||
{
|
||
sb.AppendLine($"- {candidate.RuleId} score={candidate.Score}: {candidate.Title}");
|
||
var requiredFacts = ReadRuleRequiredModelFacts(candidate.RuleDetail);
|
||
if (requiredFacts.Count > 0)
|
||
sb.AppendLine($" required_model_facts: {string.Join("; ", requiredFacts.Take(6))}");
|
||
}
|
||
}
|
||
|
||
return sb.ToString();
|
||
}
|
||
|
||
MechanicalKnowledgeRetrievalResult RetrieveKnowledgeCandidates(JsonElement semanticGraph, int maxRulesPerUnit)
|
||
{
|
||
const int wideCandidateLimit = 50;
|
||
const int judgementScoreThreshold = 45;
|
||
const int conditionalScoreThreshold = 45;
|
||
const int conditionalEvidenceThreshold = 30;
|
||
const int inspectionCardLookupThreshold = 24;
|
||
var maxJudgementCandidates = Math.Clamp(maxRulesPerUnit <= 0 ? 4 : maxRulesPerUnit, 1, 50);
|
||
var knowledgePackages = LoadRefinedMechanicalRulePackages("all");
|
||
if (knowledgePackages.Count == 0)
|
||
throw new FileNotFoundException($"no refined rule index/store packages found under: {_paths.MechanicalKnowledgeIndexRoot}");
|
||
var localUnits = ReadLocalSemanticUnits(semanticGraph);
|
||
var unitResults = new List<MechanicalRetrievedUnit>();
|
||
foreach (var unit in localUnits)
|
||
{
|
||
var unitPackages = SelectRulePackagesForUnit(knowledgePackages, unit);
|
||
var rulesById = unitPackages
|
||
.SelectMany(package => package.Rules)
|
||
.GroupBy(rule => ReadString(rule, "rule_id"), StringComparer.OrdinalIgnoreCase)
|
||
.Where(group => !string.IsNullOrWhiteSpace(group.Key))
|
||
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
|
||
|
||
var indexItems = unitPackages
|
||
.SelectMany(package => package.Items)
|
||
.Where(item => IsRefinedRuleId(ReadString(item, "rule_id")))
|
||
.GroupBy(item => ReadString(item, "rule_id"), StringComparer.OrdinalIgnoreCase)
|
||
.Where(group => !string.IsNullOrWhiteSpace(group.Key))
|
||
.Select(group => group.First())
|
||
.ToList();
|
||
|
||
var ruleDocuments = indexItems
|
||
.Select(item =>
|
||
{
|
||
var ruleId = ReadString(item, "rule_id");
|
||
rulesById.TryGetValue(ruleId, out var detail);
|
||
return BuildRuleSearchDocument(item, detail);
|
||
})
|
||
.Where(doc => !string.IsNullOrWhiteSpace(doc.RuleId))
|
||
.ToList();
|
||
var bm25Corpus = BuildBm25Corpus(ruleDocuments);
|
||
var queryText = BuildKnowledgeRetrievalQuery(unit);
|
||
var queryTerms = TokenizeForBm25(queryText);
|
||
var scoredCandidates = ruleDocuments
|
||
.Select(doc => ScoreRuleCandidate(doc, queryTerms, unit, bm25Corpus))
|
||
.Where(candidate => candidate.Score > 0)
|
||
.OrderByDescending(candidate => candidate.Score)
|
||
.ThenBy(candidate => candidate.RuleId, StringComparer.OrdinalIgnoreCase)
|
||
.GroupBy(candidate => FirstNonEmpty(candidate.RuleId, candidate.Code), StringComparer.OrdinalIgnoreCase)
|
||
.Select(group => group.First())
|
||
.Take(wideCandidateLimit)
|
||
.ToList();
|
||
var selectedCandidates = scoredCandidates
|
||
.Select(candidate =>
|
||
{
|
||
var semanticScore = candidate.ScoreBreakdown.GetValueOrDefault("bm25_semantic");
|
||
var selected =
|
||
candidate.Score >= judgementScoreThreshold ||
|
||
candidate.Score >= conditionalScoreThreshold && semanticScore >= conditionalEvidenceThreshold ||
|
||
candidate.ScoreBreakdown.ContainsKey("inspection_card_targeted_lookup") && candidate.Score >= inspectionCardLookupThreshold;
|
||
candidate.SelectedForJudgement = selected;
|
||
candidate.SelectionReason = selected
|
||
? candidate.Score >= judgementScoreThreshold
|
||
? $"score >= {judgementScoreThreshold}"
|
||
: candidate.ScoreBreakdown.ContainsKey("inspection_card_targeted_lookup")
|
||
? $"inspection card targeted lookup and score >= {inspectionCardLookupThreshold}"
|
||
: $"score >= {conditionalScoreThreshold} and bm25 semantic score >= {conditionalEvidenceThreshold}"
|
||
: "below judgement threshold";
|
||
if (selected && rulesById.TryGetValue(candidate.RuleId, out var detail))
|
||
candidate.RuleDetail = detail;
|
||
return candidate;
|
||
})
|
||
.Where(candidate => candidate.SelectedForJudgement)
|
||
.Take(maxJudgementCandidates)
|
||
.ToList();
|
||
|
||
unitResults.Add(new MechanicalRetrievedUnit
|
||
{
|
||
UnitKey = BuildUnitKey(unit.ComponentId, unit.LocalId, unit.DedupKey, unit.AnchorId),
|
||
ComponentId = unit.ComponentId,
|
||
InstanceName = unit.InstanceName,
|
||
LocalId = unit.LocalId,
|
||
DedupKey = unit.DedupKey,
|
||
DiagnosticStage = unit.DiagnosticStage,
|
||
KnowledgeScope = unit.KnowledgeScope,
|
||
RetrievalSentence = CleanKnowledgeText(unit.RetrievalSentence),
|
||
AnchorType = unit.AnchorType,
|
||
AnchorId = unit.AnchorId,
|
||
PrimaryView = unit.PrimaryView,
|
||
PatternType = unit.PatternType,
|
||
InspectionCardRefs = unit.InspectionCardRefs,
|
||
ImageEvidenceNames = unit.ImageEvidenceNames,
|
||
FactBundle = unit.FactBundle,
|
||
Candidates = selectedCandidates,
|
||
RerankedCandidates = scoredCandidates,
|
||
RetrievalSelectionPolicy = new MechanicalRetrievalSelectionPolicy
|
||
{
|
||
WideCandidateLimit = wideCandidateLimit,
|
||
JudgementScoreThreshold = judgementScoreThreshold,
|
||
ConditionalScoreThreshold = conditionalScoreThreshold,
|
||
ConditionalEvidenceThreshold = conditionalEvidenceThreshold,
|
||
InspectionCardLookupThreshold = inspectionCardLookupThreshold,
|
||
MaxJudgementCandidates = maxJudgementCandidates
|
||
}
|
||
});
|
||
}
|
||
|
||
return new MechanicalKnowledgeRetrievalResult
|
||
{
|
||
Schema = "mechanical_knowledge_retrieval_result_v1",
|
||
KnowledgeScope = string.Join("+", knowledgePackages.Select(package => package.Scope)),
|
||
IndexPath = string.Join(";", knowledgePackages.Select(package => package.IndexPath)),
|
||
StorePath = string.Join(";", knowledgePackages.Select(package => package.StorePath)),
|
||
Units = unitResults
|
||
};
|
||
}
|
||
|
||
static IReadOnlyList<MechanicalRulePackage> SelectRulePackagesForUnit(IReadOnlyList<MechanicalRulePackage> packages, MechanicalLocalUnit unit)
|
||
{
|
||
var scope = NormalizeKnowledgeScope(unit.KnowledgeScope, unit.AnchorType);
|
||
var scoped = packages.Where(package => package.Scope.Equals(scope, StringComparison.OrdinalIgnoreCase)).ToList();
|
||
return scoped.Count > 0 ? scoped : packages;
|
||
}
|
||
|
||
static bool IsFunctionalGroupKnowledgeUnit(string anchorType)
|
||
{
|
||
var normalized = NormalizeAnchorType(anchorType);
|
||
return normalized is "functional_group" or "interface";
|
||
}
|
||
|
||
List<MechanicalRulePackage> LoadRefinedMechanicalRulePackages(string knowledgeScope)
|
||
{
|
||
var specs = new (string Scope, string Index, string Store, Func<JsonElement, bool> Filter)[]
|
||
{
|
||
("part_knowledge", "chapter18_rule_index_v1.json", "chapter18_rule_store_v1.json", rule => RuleBelongsToChapter(rule, 18)),
|
||
("assembly_knowledge", "chapter19_24_rule_index_v1.json", "chapter19_24_rule_store_v1.json", rule => RuleBelongsToChapter(rule, 19))
|
||
};
|
||
var packages = new List<MechanicalRulePackage>();
|
||
foreach (var spec in specs)
|
||
{
|
||
if (!knowledgeScope.Equals("all", StringComparison.OrdinalIgnoreCase) &&
|
||
!knowledgeScope.Equals(spec.Scope, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
continue;
|
||
}
|
||
var indexPath = Path.Combine(_paths.MechanicalKnowledgeIndexRoot, spec.Index);
|
||
var storePath = Path.Combine(_paths.MechanicalKnowledgeIndexRoot, spec.Store);
|
||
if (!File.Exists(indexPath) || !File.Exists(storePath))
|
||
continue;
|
||
|
||
using var indexDoc = JsonDocument.Parse(File.ReadAllText(indexPath));
|
||
using var storeDoc = JsonDocument.Parse(File.ReadAllText(storePath));
|
||
var rules = storeDoc.RootElement.TryGetProperty("rules", out var ruleArray) && ruleArray.ValueKind == JsonValueKind.Array
|
||
? ruleArray.EnumerateArray()
|
||
.Where(rule => IsRefinedRuleId(ReadString(rule, "rule_id")))
|
||
.Where(spec.Filter)
|
||
.Select(rule => rule.Clone())
|
||
.ToList()
|
||
: [];
|
||
var items = indexDoc.RootElement.TryGetProperty("items", out var itemArray) && itemArray.ValueKind == JsonValueKind.Array
|
||
? itemArray.EnumerateArray()
|
||
.Where(item => IsRefinedRuleId(ReadString(item, "rule_id")))
|
||
.Where(spec.Filter)
|
||
.Select(item => item.Clone())
|
||
.ToList()
|
||
: [];
|
||
packages.Add(new MechanicalRulePackage
|
||
{
|
||
Scope = spec.Scope,
|
||
IndexPath = indexPath,
|
||
StorePath = storePath,
|
||
Items = items,
|
||
Rules = rules
|
||
});
|
||
}
|
||
|
||
return packages;
|
||
}
|
||
|
||
static bool RuleBelongsToChapter(JsonElement rule, int chapter)
|
||
{
|
||
var prefix = chapter.ToString(CultureInfo.InvariantCulture) + ".";
|
||
if (ReadString(rule, "rule_id").StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ||
|
||
ReadString(rule, "code").StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
if (rule.TryGetProperty("source_semantics", out var source) &&
|
||
source.ValueKind == JsonValueKind.Object &&
|
||
source.TryGetProperty("chapter", out var chapterValue) &&
|
||
chapterValue.ValueKind == JsonValueKind.Number &&
|
||
chapterValue.TryGetInt32(out var value))
|
||
{
|
||
return value == chapter;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
static void WriteKnowledgeRetrievalArtifacts(string outputDir, MechanicalKnowledgeRetrievalResult retrieval)
|
||
{
|
||
Directory.CreateDirectory(outputDir);
|
||
var reranked = new
|
||
{
|
||
retrieval.Schema,
|
||
retrieval.KnowledgeScope,
|
||
units = retrieval.Units.Select(unit => new
|
||
{
|
||
unit.UnitKey,
|
||
unit.ComponentId,
|
||
unit.InstanceName,
|
||
unit.LocalId,
|
||
unit.DiagnosticStage,
|
||
unit.KnowledgeScope,
|
||
unit.RetrievalSentence,
|
||
unit.PrimaryView,
|
||
unit.PatternType,
|
||
unit.InspectionCardRefs,
|
||
unit.RetrievalSelectionPolicy,
|
||
candidates = unit.RerankedCandidates
|
||
})
|
||
};
|
||
var selected = new
|
||
{
|
||
retrieval.Schema,
|
||
retrieval.KnowledgeScope,
|
||
units = retrieval.Units.Select(unit => new
|
||
{
|
||
unit.UnitKey,
|
||
unit.ComponentId,
|
||
unit.InstanceName,
|
||
unit.LocalId,
|
||
unit.DiagnosticStage,
|
||
unit.KnowledgeScope,
|
||
unit.RetrievalSentence,
|
||
unit.PrimaryView,
|
||
unit.PatternType,
|
||
unit.InspectionCardRefs,
|
||
unit.RetrievalSelectionPolicy,
|
||
candidates = unit.Candidates
|
||
})
|
||
};
|
||
File.WriteAllText(Path.Combine(outputDir, "knowledge_retrieval_reranked_candidates.json"), JsonSerializer.Serialize(reranked, MechanicalJsonOptions.Default), new UTF8Encoding(false));
|
||
File.WriteAllText(Path.Combine(outputDir, "knowledge_retrieval_selected_for_judgement.json"), JsonSerializer.Serialize(selected, MechanicalJsonOptions.Default), new UTF8Encoding(false));
|
||
}
|
||
|
||
static void WriteStagedKnowledgeRetrievalArtifacts(string outputDir, MechanicalKnowledgeRetrievalResult retrieval)
|
||
{
|
||
WriteStage(
|
||
"knowledge_retrieval_functional_group_candidates.json",
|
||
MechanicalDiagnosticFlowTerms.StageFunctionalGroupCheck,
|
||
MechanicalDiagnosticFlowTerms.KnowledgeScopeAssembly,
|
||
retrieval.Units.Where(IsFunctionalGroupJudgementUnit).ToList());
|
||
WriteStage(
|
||
"knowledge_retrieval_group_internal_part_candidates.json",
|
||
MechanicalDiagnosticFlowTerms.StageGroupInternalPartCheck,
|
||
MechanicalDiagnosticFlowTerms.KnowledgeScopePart,
|
||
retrieval.Units.Where(unit => !IsFunctionalGroupJudgementUnit(unit)).ToList());
|
||
|
||
void WriteStage(string fileName, string stage, string scope, IReadOnlyList<MechanicalRetrievedUnit> units)
|
||
{
|
||
File.WriteAllText(Path.Combine(outputDir, fileName), JsonSerializer.Serialize(new
|
||
{
|
||
schema = "mechanical_staged_knowledge_retrieval_v1",
|
||
diagnostic_stage = stage,
|
||
knowledge_scope = scope,
|
||
unit_count = units.Count,
|
||
selected_candidate_count = units.SelectMany(unit => unit.Candidates).Count(),
|
||
units
|
||
}, MechanicalJsonOptions.Default), new UTF8Encoding(false));
|
||
}
|
||
}
|
||
|
||
static void WriteRuleJudgementInputs(
|
||
string outputDir,
|
||
MechanicalKnowledgeRetrievalResult retrieval,
|
||
IReadOnlyDictionary<string, List<MechanicalEvidenceImage>> evidenceImagesByUnit)
|
||
{
|
||
Directory.CreateDirectory(outputDir);
|
||
var inputs = retrieval.Units
|
||
.SelectMany(unit =>
|
||
{
|
||
evidenceImagesByUnit.TryGetValue(unit.UnitKey, out var images);
|
||
var imageNames = images?.Select(image => image.Name).ToList() ?? [];
|
||
return unit.Candidates.Select(candidate => new
|
||
{
|
||
unit_key = unit.UnitKey,
|
||
component_id = unit.ComponentId,
|
||
instance_name = unit.InstanceName,
|
||
local_id = unit.LocalId,
|
||
diagnostic_stage = unit.DiagnosticStage,
|
||
knowledge_scope = unit.KnowledgeScope,
|
||
retrieval_sentence = unit.RetrievalSentence,
|
||
anchor_type = unit.AnchorType,
|
||
anchor_id = unit.AnchorId,
|
||
primary_view = unit.PrimaryView,
|
||
pattern_type = unit.PatternType,
|
||
inspection_card_refs = unit.InspectionCardRefs,
|
||
rule_id = candidate.RuleId,
|
||
rule_title = candidate.Title,
|
||
rule_retrieval_sentence = candidate.RuleRetrievalSentence,
|
||
retrieval_score = candidate.Score,
|
||
candidate_score_breakdown = candidate.ScoreBreakdown,
|
||
candidate_selection_reason = candidate.SelectionReason,
|
||
rule_verification = new
|
||
{
|
||
required_model_facts = ReadRuleRequiredModelFacts(candidate.RuleDetail),
|
||
bad_if = ReadRuleVerificationString(candidate.RuleDetail, "bad_if"),
|
||
good_if = ReadRuleVerificationString(candidate.RuleDetail, "good_if"),
|
||
unknown_if = ReadRuleVerificationArray(candidate.RuleDetail, "unknown_if")
|
||
},
|
||
available_evidence = new
|
||
{
|
||
fact_bundle = unit.FactBundle,
|
||
semantic_image_evidence_names = unit.ImageEvidenceNames,
|
||
evidence_image_names = imageNames,
|
||
expected_evidence_resolution_order = new[]
|
||
{
|
||
"fact_bundle",
|
||
"B-rep geometry and measurements already present in the local subgraph fact bundle",
|
||
"contact relations already present in the local subgraph fact bundle",
|
||
"SolidWorks mate relations already present in the local subgraph fact bundle",
|
||
"attached evidence images",
|
||
"AI semantic inference for process role or function facts that cannot be directly extracted"
|
||
}
|
||
}
|
||
});
|
||
})
|
||
.ToList();
|
||
|
||
File.WriteAllText(Path.Combine(outputDir, "rule_judgement_inputs.json"), JsonSerializer.Serialize(new
|
||
{
|
||
schema = "mechanical_rule_judgement_inputs_v1",
|
||
retrieval.KnowledgeScope,
|
||
inputs
|
||
}, MechanicalJsonOptions.Default), new UTF8Encoding(false));
|
||
}
|
||
|
||
static string ReadRuleVerificationString(JsonElement ruleDetail, string property)
|
||
{
|
||
if (ruleDetail.ValueKind != JsonValueKind.Object ||
|
||
!ruleDetail.TryGetProperty("verification_rule", out var verificationRule) ||
|
||
verificationRule.ValueKind != JsonValueKind.Object)
|
||
{
|
||
return "";
|
||
}
|
||
|
||
return ReadString(verificationRule, property);
|
||
}
|
||
|
||
static List<string> ReadRuleVerificationArray(JsonElement ruleDetail, string property)
|
||
{
|
||
if (ruleDetail.ValueKind != JsonValueKind.Object ||
|
||
!ruleDetail.TryGetProperty("verification_rule", out var verificationRule) ||
|
||
verificationRule.ValueKind != JsonValueKind.Object)
|
||
{
|
||
return [];
|
||
}
|
||
|
||
return ReadStringArray(verificationRule, property);
|
||
}
|
||
|
||
static List<MechanicalLocalUnit> ReadLocalSemanticUnits(JsonElement semanticGraph)
|
||
{
|
||
var units = new List<MechanicalLocalUnit>();
|
||
var hasFormalLocalSemanticUnits = false;
|
||
if (semanticGraph.TryGetProperty("local_semantic_units", out var localSemanticUnits) && localSemanticUnits.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var unit in localSemanticUnits.EnumerateArray())
|
||
{
|
||
hasFormalLocalSemanticUnits = true;
|
||
var componentId = ExtractComponentId(unit);
|
||
var instanceName = ExtractInstanceName(unit);
|
||
units.Add(new MechanicalLocalUnit
|
||
{
|
||
ComponentId = componentId,
|
||
InstanceName = instanceName,
|
||
LocalId = FirstNonEmpty(ReadString(unit, "local_id"), ReadString(unit, "id")),
|
||
DedupKey = ReadString(unit, "dedup_key"),
|
||
DiagnosticStage = NormalizeDiagnosticStage(ReadString(unit, "diagnostic_stage"), ReadString(unit, "anchor_type")),
|
||
KnowledgeScope = NormalizeKnowledgeScope(ReadString(unit, "knowledge_scope"), ReadString(unit, "anchor_type")),
|
||
AnchorType = NormalizeAnchorType(ReadString(unit, "anchor_type")),
|
||
AnchorId = ReadString(unit, "anchor_id"),
|
||
PrimaryView = FirstNonEmpty(ReadString(unit, "primary_view"), InferPrimaryView(ReadString(unit, "pattern_type"))),
|
||
SecondaryViews = ReadStringArray(unit, "secondary_views"),
|
||
RetrievalSentence = FirstNonEmpty(
|
||
ReadString(unit, "retrieval_sentence"),
|
||
string.Join(" ", ReadStringArray(unit, "retrieval_keywords")),
|
||
BuildLocalUnitSearchText(unit)),
|
||
RetrievalChain = unit.TryGetProperty("retrieval_chain", out var rc) ? rc.Clone() : default,
|
||
InspectionCardRefs = ReadInspectionCardRefs(unit),
|
||
RetrievalSemanticRoles = ReadStringArray(unit, "retrieval_semantic_roles")
|
||
.Concat(ReadStringArray(unit, "semantic_roles"))
|
||
.ToList(),
|
||
InferredFunctions = ReadStringArray(unit, "inferred_functions"),
|
||
RetrievalFactTags = ReadStringArray(unit, "retrieval_fact_tags"),
|
||
RetrievalRelations = ReadStringArray(unit, "retrieval_relations"),
|
||
ImageEvidenceNames = ReadStringArray(unit, "image_evidence_names"),
|
||
InferredAttributesText = ExtractObjectPropertyText(unit, "inferred_attributes"),
|
||
NormalizedMeasurementsText = ExtractObjectPropertyText(unit, "normalized_measurements"),
|
||
PatternType = ReadString(unit, "pattern_type"),
|
||
FactBundle = unit.TryGetProperty("fact_bundle", out var fb) ? fb.Clone() : default
|
||
});
|
||
}
|
||
}
|
||
|
||
if (!hasFormalLocalSemanticUnits &&
|
||
semanticGraph.TryGetProperty("local_subgraphs", out var localSubgraphs) &&
|
||
localSubgraphs.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var subgraph in localSubgraphs.EnumerateArray())
|
||
{
|
||
var retrievalSentence = FirstNonEmpty(
|
||
ReadString(subgraph, "retrieval_sentence"),
|
||
ReadString(subgraph, "evidence"),
|
||
string.Join(" ", ReadStringArray(subgraph, "retrieval_keywords")),
|
||
BuildLocalUnitSearchText(subgraph));
|
||
var patternType = ReadString(subgraph, "pattern_type");
|
||
var componentId = ExtractComponentId(subgraph);
|
||
var instanceName = ExtractInstanceName(subgraph);
|
||
units.Add(new MechanicalLocalUnit
|
||
{
|
||
ComponentId = componentId,
|
||
InstanceName = instanceName,
|
||
LocalId = FirstNonEmpty(ReadString(subgraph, "local_id"), ReadString(subgraph, "id")),
|
||
DiagnosticStage = NormalizeDiagnosticStage(ReadString(subgraph, "diagnostic_stage"), ReadString(subgraph, "anchor_type")),
|
||
KnowledgeScope = NormalizeKnowledgeScope(ReadString(subgraph, "knowledge_scope"), ReadString(subgraph, "anchor_type")),
|
||
AnchorType = NormalizeAnchorType(ReadString(subgraph, "anchor_type")),
|
||
AnchorId = ReadString(subgraph, "anchor_id"),
|
||
PrimaryView = FirstNonEmpty(ReadString(subgraph, "primary_view"), InferPrimaryView(patternType)),
|
||
SecondaryViews = ReadStringArray(subgraph, "secondary_views"),
|
||
RetrievalSentence = retrievalSentence,
|
||
InspectionCardRefs = ReadInspectionCardRefs(subgraph),
|
||
RetrievalSemanticRoles = ReadStringArray(subgraph, "roles"),
|
||
InferredFunctions = ReadStringArray(subgraph, "inferred_functions"),
|
||
RetrievalFactTags = ReadStringArray(subgraph, "retrieval_fact_tags"),
|
||
RetrievalRelations = ReadStringArray(subgraph, "retrieval_relations"),
|
||
ImageEvidenceNames = ReadStringArray(subgraph, "image_evidence_names"),
|
||
InferredAttributesText = ExtractObjectPropertyText(subgraph, "inferred_attributes"),
|
||
NormalizedMeasurementsText = ExtractObjectPropertyText(subgraph, "normalized_measurements"),
|
||
PatternType = patternType,
|
||
FactBundle = subgraph.Clone()
|
||
});
|
||
}
|
||
}
|
||
|
||
return units
|
||
.Where(unit => !string.IsNullOrWhiteSpace(unit.RetrievalSentence) || !string.IsNullOrWhiteSpace(unit.PatternType))
|
||
.GroupBy(unit => FirstNonEmpty(
|
||
BuildUnitKey(unit.ComponentId, unit.LocalId, unit.DedupKey, unit.AnchorId),
|
||
$"{unit.PrimaryView}|{unit.PatternType}|{CleanKnowledgeText(unit.RetrievalSentence)}"),
|
||
StringComparer.OrdinalIgnoreCase)
|
||
.Select(group =>
|
||
{
|
||
var unit = group.First();
|
||
unit.UnitKey = BuildUnitKey(unit.ComponentId, unit.LocalId, unit.DedupKey, unit.AnchorId);
|
||
return unit;
|
||
})
|
||
.ToList();
|
||
}
|
||
|
||
static List<string> ReadInspectionCardRefs(JsonElement element)
|
||
{
|
||
var refs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
AddRefs(element, "inspection_card_refs", refs);
|
||
AddRefs(element, "inspection_card_rule_ids", refs);
|
||
AddRefs(element, "candidate_rule_ids", refs);
|
||
AddRefs(element, "checked_rule_ids", refs);
|
||
if (element.TryGetProperty("fact_bundle", out var factBundle) && factBundle.ValueKind == JsonValueKind.Object)
|
||
{
|
||
AddRefs(factBundle, "inspection_card_refs", refs);
|
||
AddRefs(factBundle, "inspection_card_rule_ids", refs);
|
||
AddRefs(factBundle, "candidate_rule_ids", refs);
|
||
AddRefs(factBundle, "checked_rule_ids", refs);
|
||
}
|
||
|
||
return refs
|
||
.Where(IsRefinedRuleId)
|
||
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
}
|
||
|
||
static string NormalizeDiagnosticStage(string value, string anchorType)
|
||
{
|
||
var text = value.Trim().ToLowerInvariant();
|
||
if (text is MechanicalDiagnosticFlowTerms.StageSinglePartCheck
|
||
or MechanicalDiagnosticFlowTerms.StageFunctionalGroupCheck
|
||
or MechanicalDiagnosticFlowTerms.StageGroupInternalPartCheck)
|
||
return text;
|
||
|
||
return IsFunctionalGroupKnowledgeUnit(anchorType)
|
||
? MechanicalDiagnosticFlowTerms.StageFunctionalGroupCheck
|
||
: MechanicalDiagnosticFlowTerms.StageGroupInternalPartCheck;
|
||
}
|
||
|
||
static string NormalizeKnowledgeScope(string value, string anchorType)
|
||
{
|
||
var text = value.Trim().ToLowerInvariant();
|
||
if (text is MechanicalDiagnosticFlowTerms.KnowledgeScopePart
|
||
or MechanicalDiagnosticFlowTerms.KnowledgeScopeAssembly)
|
||
return text;
|
||
|
||
return IsFunctionalGroupKnowledgeUnit(anchorType)
|
||
? MechanicalDiagnosticFlowTerms.KnowledgeScopeAssembly
|
||
: MechanicalDiagnosticFlowTerms.KnowledgeScopePart;
|
||
}
|
||
|
||
static void AddRefs(JsonElement element, string propertyName, ISet<string> refs)
|
||
{
|
||
if (!element.TryGetProperty(propertyName, out var value))
|
||
return;
|
||
|
||
if (value.ValueKind == JsonValueKind.String)
|
||
{
|
||
AddRuleRef(value.GetString(), refs);
|
||
return;
|
||
}
|
||
|
||
if (value.ValueKind != JsonValueKind.Array)
|
||
return;
|
||
|
||
foreach (var item in value.EnumerateArray())
|
||
{
|
||
if (item.ValueKind == JsonValueKind.String)
|
||
{
|
||
AddRuleRef(item.GetString(), refs);
|
||
continue;
|
||
}
|
||
|
||
if (item.ValueKind == JsonValueKind.Object)
|
||
{
|
||
AddRuleRef(ReadString(item, "rule_id"), refs);
|
||
AddRuleRef(ReadString(item, "id"), refs);
|
||
AddRuleRef(ReadString(item, "code"), refs);
|
||
}
|
||
}
|
||
}
|
||
|
||
static void AddRuleRef(string? value, ISet<string> refs)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(value))
|
||
return;
|
||
|
||
var cleaned = value.Trim();
|
||
if (IsRefinedRuleId(cleaned))
|
||
refs.Add(cleaned);
|
||
}
|
||
|
||
static string ExtractComponentId(JsonElement element) =>
|
||
FirstNonEmpty(
|
||
ReadString(element, "component_id"),
|
||
ReadString(element, "componentId"),
|
||
FindString(element, "component_id", "componentId"));
|
||
|
||
static string ExtractInstanceName(JsonElement element) =>
|
||
FirstNonEmpty(
|
||
ReadString(element, "instance_name"),
|
||
ReadString(element, "instanceName"),
|
||
ReadString(element, "component_name"),
|
||
ReadString(element, "display_name"),
|
||
FindString(element, "instance_name", "instanceName", "component_name", "display_name"));
|
||
|
||
static string BuildUnitKey(string componentId, string localId, string dedupKey, string anchorId = "")
|
||
{
|
||
var unitId = FirstNonEmpty(dedupKey, anchorId, localId);
|
||
if (!string.IsNullOrWhiteSpace(componentId) && !string.IsNullOrWhiteSpace(unitId))
|
||
return $"{componentId}::{unitId}";
|
||
return FirstNonEmpty(unitId, componentId, "unit");
|
||
}
|
||
|
||
static MechanicalRuleSearchDocument BuildRuleSearchDocument(JsonElement item, JsonElement ruleDetail)
|
||
{
|
||
var retrieval = item.TryGetProperty("retrieval_unit", out var r) ? r : default;
|
||
var ruleRetrievalSentence = ReadString(retrieval, "retrieval_sentence");
|
||
var primaryView = ReadString(item, "primary_view");
|
||
var secondaryViews = ReadStringArray(item, "secondary_views");
|
||
var requiredFacts = ReadRuleRequiredModelFacts(ruleDetail);
|
||
var text = BuildRuleRetrievalText(item, ruleDetail, retrieval);
|
||
var terms = TokenizeForBm25(text);
|
||
return new MechanicalRuleSearchDocument
|
||
{
|
||
Item = item.Clone(),
|
||
Detail = ruleDetail.ValueKind == JsonValueKind.Undefined ? EmptyJsonObject() : ruleDetail.Clone(),
|
||
RuleId = ReadString(item, "rule_id"),
|
||
Code = ReadString(item, "code"),
|
||
Title = ReadString(item, "title"),
|
||
PrimaryView = primaryView,
|
||
SecondaryViews = secondaryViews,
|
||
RequiredModelFacts = requiredFacts,
|
||
RuleRetrievalSentence = ruleRetrievalSentence,
|
||
RetrievalText = text,
|
||
Terms = terms,
|
||
TermCounts = terms
|
||
.GroupBy(term => term, StringComparer.OrdinalIgnoreCase)
|
||
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase)
|
||
};
|
||
}
|
||
|
||
static string BuildRuleRetrievalText(JsonElement item, JsonElement ruleDetail, JsonElement retrieval)
|
||
{
|
||
var parts = new List<string>
|
||
{
|
||
ReadString(item, "title"),
|
||
ReadString(item, "knowledge_category"),
|
||
ReadString(retrieval, "retrieval_sentence"),
|
||
string.Join(' ', ReadStringArray(retrieval, "aliases").Select(alias => TrimKnowledgeText(alias, 140))),
|
||
string.Join(' ', ReadStringArray(item, "bad_if_tags")),
|
||
string.Join(' ', ReadStringArray(item, "good_if_tags"))
|
||
};
|
||
|
||
if (ruleDetail.ValueKind == JsonValueKind.Object)
|
||
{
|
||
parts.Add(ReadString(ruleDetail, "title"));
|
||
parts.Add(ReadString(ruleDetail, "knowledge_category"));
|
||
if (ruleDetail.TryGetProperty("verification_rule", out var verificationRule) &&
|
||
verificationRule.ValueKind == JsonValueKind.Object)
|
||
{
|
||
parts.Add(ReadString(verificationRule, "bad_if"));
|
||
parts.Add(ReadString(verificationRule, "good_if"));
|
||
}
|
||
}
|
||
|
||
return CleanKnowledgeText(string.Join(" ", parts.Where(value => !string.IsNullOrWhiteSpace(value))));
|
||
}
|
||
|
||
static string TrimKnowledgeText(string value, int maxChars)
|
||
{
|
||
var cleaned = CleanKnowledgeText(value);
|
||
if (string.IsNullOrWhiteSpace(cleaned) || cleaned.Length <= maxChars)
|
||
return cleaned;
|
||
|
||
return cleaned[..maxChars];
|
||
}
|
||
|
||
static MechanicalBm25Corpus BuildBm25Corpus(IReadOnlyList<MechanicalRuleSearchDocument> documents)
|
||
{
|
||
var documentFrequency = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var doc in documents)
|
||
{
|
||
foreach (var term in doc.TermCounts.Keys)
|
||
documentFrequency[term] = documentFrequency.GetValueOrDefault(term) + 1;
|
||
}
|
||
|
||
var documentCount = Math.Max(1, documents.Count);
|
||
var averageLength = documents.Count == 0 ? 1.0 : Math.Max(1.0, documents.Average(doc => doc.Terms.Count));
|
||
var idf = documentFrequency.ToDictionary(
|
||
item => item.Key,
|
||
item => Math.Log(1.0 + (documentCount - item.Value + 0.5) / (item.Value + 0.5)),
|
||
StringComparer.OrdinalIgnoreCase);
|
||
|
||
return new MechanicalBm25Corpus
|
||
{
|
||
DocumentCount = documentCount,
|
||
AverageDocumentLength = averageLength,
|
||
InverseDocumentFrequency = idf
|
||
};
|
||
}
|
||
|
||
static MechanicalRuleCandidate ScoreRuleCandidate(
|
||
MechanicalRuleSearchDocument doc,
|
||
IReadOnlyList<string> queryTerms,
|
||
MechanicalLocalUnit unit,
|
||
MechanicalBm25Corpus corpus)
|
||
{
|
||
var bm25 = ComputeBm25(queryTerms, doc, corpus);
|
||
var bm25Semantic = Math.Min(70, (int)Math.Round(14.0 * Math.Log(1.0 + bm25)));
|
||
var scoreBreakdown = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["bm25_semantic"] = bm25Semantic,
|
||
["bm25_raw_x100"] = (int)Math.Round(bm25 * 100.0)
|
||
};
|
||
|
||
var structureScore = ScoreWeakStructure(doc, unit, scoreBreakdown);
|
||
var intentAdjustment = ScoreSemanticIntentAlignment(doc, unit, scoreBreakdown);
|
||
var inspectionCardScore = ScoreInspectionCardReference(doc, unit, scoreBreakdown);
|
||
var score = Math.Max(0, bm25Semantic + structureScore + intentAdjustment + inspectionCardScore);
|
||
|
||
return new MechanicalRuleCandidate
|
||
{
|
||
RuleId = doc.RuleId,
|
||
Code = doc.Code,
|
||
Title = doc.Title,
|
||
PrimaryView = doc.PrimaryView,
|
||
RuleRetrievalSentence = doc.RuleRetrievalSentence,
|
||
Score = score,
|
||
ScoreBreakdown = scoreBreakdown,
|
||
ViewMatched = scoreBreakdown.ContainsKey("primary_view_weak_match") ||
|
||
scoreBreakdown.ContainsKey("secondary_view_weak_match"),
|
||
IndexEntry = doc.Item,
|
||
RuleDetail = doc.Detail
|
||
};
|
||
}
|
||
|
||
static int ScoreInspectionCardReference(
|
||
MechanicalRuleSearchDocument doc,
|
||
MechanicalLocalUnit unit,
|
||
Dictionary<string, int> breakdown)
|
||
{
|
||
if (unit.InspectionCardRefs.Count == 0 || string.IsNullOrWhiteSpace(doc.RuleId))
|
||
return 0;
|
||
|
||
if (!unit.InspectionCardRefs.Contains(doc.RuleId, StringComparer.OrdinalIgnoreCase))
|
||
return 0;
|
||
|
||
const int score = 28;
|
||
breakdown["inspection_card_targeted_lookup"] = score;
|
||
return score;
|
||
}
|
||
|
||
static double ComputeBm25(
|
||
IReadOnlyList<string> queryTerms,
|
||
MechanicalRuleSearchDocument doc,
|
||
MechanicalBm25Corpus corpus)
|
||
{
|
||
if (queryTerms.Count == 0 || doc.Terms.Count == 0)
|
||
return 0;
|
||
|
||
const double k1 = 1.4;
|
||
const double b = 0.75;
|
||
var score = 0.0;
|
||
foreach (var term in queryTerms.Distinct(StringComparer.OrdinalIgnoreCase))
|
||
{
|
||
if (!doc.TermCounts.TryGetValue(term, out var tf) || tf <= 0)
|
||
continue;
|
||
|
||
corpus.InverseDocumentFrequency.TryGetValue(term, out var idf);
|
||
var lengthNorm = k1 * (1.0 - b + b * doc.Terms.Count / corpus.AverageDocumentLength);
|
||
score += idf * (tf * (k1 + 1.0)) / (tf + lengthNorm);
|
||
}
|
||
|
||
return score;
|
||
}
|
||
|
||
static int ScoreWeakStructure(MechanicalRuleSearchDocument doc, MechanicalLocalUnit unit, Dictionary<string, int> breakdown)
|
||
{
|
||
var score = 0;
|
||
if (!string.IsNullOrWhiteSpace(unit.PrimaryView) &&
|
||
string.Equals(unit.PrimaryView, doc.PrimaryView, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
score += 6;
|
||
breakdown["primary_view_weak_match"] = 6;
|
||
}
|
||
|
||
if (unit.SecondaryViews.Any(view =>
|
||
string.Equals(view, doc.PrimaryView, StringComparison.OrdinalIgnoreCase) ||
|
||
doc.SecondaryViews.Contains(view, StringComparer.OrdinalIgnoreCase)))
|
||
{
|
||
score += 4;
|
||
breakdown["secondary_view_weak_match"] = 4;
|
||
}
|
||
|
||
var ruleText = doc.RetrievalText;
|
||
if (!string.IsNullOrWhiteSpace(unit.PatternType) &&
|
||
ruleText.Contains(unit.PatternType, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
score += 4;
|
||
breakdown["pattern_type_weak_match"] = 4;
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(unit.PatternType) && PatternMatchesRule(unit.PatternType, ruleText))
|
||
{
|
||
score += 2;
|
||
breakdown["pattern_type_weak_compatible"] = 2;
|
||
}
|
||
|
||
var semanticRoleScore = Math.Min(8, CountSemanticMatches(unit.RetrievalSemanticRoles, ruleText, doc.RequiredModelFacts) * 2);
|
||
if (semanticRoleScore > 0)
|
||
{
|
||
score += semanticRoleScore;
|
||
breakdown["semantic_role_weak_match"] = semanticRoleScore;
|
||
}
|
||
|
||
var factTagScore = Math.Min(6, CountSemanticMatches(unit.RetrievalFactTags, ruleText, doc.RequiredModelFacts) * 2);
|
||
if (factTagScore > 0)
|
||
{
|
||
score += factTagScore;
|
||
breakdown["fact_tag_weak_match"] = factTagScore;
|
||
}
|
||
|
||
var functionScore = Math.Min(4, CountSemanticMatches(unit.InferredFunctions, ruleText, doc.RequiredModelFacts) * 2);
|
||
if (functionScore > 0)
|
||
{
|
||
score += functionScore;
|
||
breakdown["function_weak_match"] = functionScore;
|
||
}
|
||
|
||
var attributeScore = Math.Min(4, CountTokenOverlap(unit.InferredAttributesText, ruleText));
|
||
if (attributeScore > 0)
|
||
{
|
||
score += attributeScore;
|
||
breakdown["attribute_weak_match"] = attributeScore;
|
||
}
|
||
|
||
var measurementScore = Math.Min(3, CountTokenOverlap(unit.NormalizedMeasurementsText, ruleText));
|
||
if (measurementScore > 0)
|
||
{
|
||
score += measurementScore;
|
||
breakdown["measurement_weak_match"] = measurementScore;
|
||
}
|
||
|
||
return Math.Min(30, score);
|
||
}
|
||
|
||
static int ScoreSemanticIntentAlignment(MechanicalRuleSearchDocument doc, MechanicalLocalUnit unit, Dictionary<string, int> breakdown)
|
||
{
|
||
var unitText = BuildUnitIntentText(unit);
|
||
var ruleText = doc.RetrievalText;
|
||
var score = 0;
|
||
|
||
var unitHasHole = ContainsAny(unitText, "孔", "圆柱孔", "通孔", "螺纹孔", "hole", "drill", "fastener_hole", "hole_group");
|
||
var unitHasPlanarContact = ContainsAny(unitText, "平面接触", "共面", "接触面积", "planar", "contact", "mating_interface", "face_contact");
|
||
var unitHasCylindricalFit = ContainsAny(unitText, "同轴", "圆柱配合", "配合接口", "止口", "coaxial", "cylindrical_fit", "centering");
|
||
var unitHasAssemblyContext = ContainsAny(unitText, "装配", "配合", "接触", "assembly", "mating", "contact", "cross_part");
|
||
var unitHasCrossPartMachiningEvidence = ContainsAny(unitText, "跨零件", "多个零件", "组合加工", "共同加工", "装配后加工", "cross_part", "requires_match_machining");
|
||
var unitHasFlangeEvidence = ContainsAny(unitText, "凸缘", "法兰", "方形", "非圆", "flange", "square", "non_axisymmetric");
|
||
var unitHasCastingEvidence = ContainsAny(unitText, "铸造", "铸件", "毛坯", "余量", "casting", "cast");
|
||
var unitHasComplexSplitEvidence = ContainsAny(unitText, "复杂", "分体", "组合件", "偏心", "薄壁", "套筒", "complex", "separable", "split", "thin_wall", "eccentric");
|
||
var unitHasSplitJointEvidence = ContainsAny(unitText, "分箱", "箱体", "刮研", "大面积平面", "split_joint", "housing", "large_planar_face", "flatness");
|
||
|
||
var ruleIsHole = ContainsAny(ruleText, "孔", "钻孔", "螺纹孔", "通孔", "hole", "drill", "tap_drill", "threaded_hole", "hole_axis");
|
||
var ruleIsPlanar = ContainsAny(ruleText, "平面", "加工面", "不加工面", "接触面", "face_area", "flatness", "planar", "split_joint_face");
|
||
var ruleIsCylindricalFit = ContainsAny(ruleText, "同轴", "止口", "对中", "外圆", "圆柱", "register_fit", "centering", "cylindrical");
|
||
var ruleNeedsMultiPart = ContainsAny(ruleText, "多个零件", "组合加工", "cross_part", "multi_part_assembly", "requires_match_machining");
|
||
var ruleNeedsFlange = ContainsAny(ruleText, "方形凸缘", "凸缘", "法兰", "non_axisymmetric_boundary", "flange_profile");
|
||
var ruleNeedsCasting = ContainsAny(ruleText, "铸造", "铸件", "铸造误差", "cast_housing", "casting_position_tolerance");
|
||
var ruleNeedsComplexSplit = ContainsAny(ruleText, "复杂的零件改为组合件", "复杂零件改为组合件", "separable_subfeature", "split_interface_candidate", "thin_wall_tubular_feature", "eccentric_or_small_axis_features");
|
||
var ruleNeedsSplitJoint = ContainsAny(ruleText, "分箱面", "刮研", "split_joint_face", "large_planar_face");
|
||
|
||
if (unitHasHole && ruleIsHole)
|
||
score += 4;
|
||
if (unitHasPlanarContact && ruleIsPlanar)
|
||
score += 4;
|
||
if (unitHasCylindricalFit && ruleIsCylindricalFit)
|
||
score += 4;
|
||
|
||
if (!unitHasHole && ruleIsHole)
|
||
score -= unitHasPlanarContact ? 14 : 8;
|
||
if (unitHasHole && ruleIsPlanar && !unitHasPlanarContact)
|
||
score -= 6;
|
||
if (unitHasPlanarContact && ruleIsCylindricalFit && !unitHasCylindricalFit)
|
||
score -= 8;
|
||
if (unitHasCylindricalFit && ruleIsPlanar && !unitHasPlanarContact)
|
||
score -= 6;
|
||
if (ruleNeedsMultiPart && !unitHasCrossPartMachiningEvidence)
|
||
score -= unitHasAssemblyContext ? 8 : 12;
|
||
if (ruleNeedsFlange && !unitHasFlangeEvidence)
|
||
score -= 12;
|
||
if (ruleNeedsCasting && !unitHasCastingEvidence)
|
||
score -= 14;
|
||
if (ruleNeedsComplexSplit && !unitHasComplexSplitEvidence)
|
||
score -= 10;
|
||
if (ruleNeedsSplitJoint && !unitHasSplitJointEvidence)
|
||
score -= 8;
|
||
|
||
if (score > 0)
|
||
breakdown["semantic_intent_bonus"] = score;
|
||
else if (score < 0)
|
||
breakdown["semantic_intent_penalty"] = score;
|
||
|
||
return Math.Clamp(score, -18, 8);
|
||
}
|
||
|
||
static string BuildUnitIntentText(MechanicalLocalUnit unit)
|
||
{
|
||
return CleanKnowledgeText(string.Join(" ", new[]
|
||
{
|
||
unit.RetrievalSentence,
|
||
unit.PrimaryView,
|
||
unit.PatternType,
|
||
string.Join(" ", unit.RetrievalSemanticRoles),
|
||
string.Join(" ", unit.InferredFunctions),
|
||
string.Join(" ", unit.RetrievalFactTags),
|
||
string.Join(" ", unit.RetrievalRelations),
|
||
string.Join(" ", unit.InspectionCardRefs),
|
||
unit.InferredAttributesText,
|
||
unit.NormalizedMeasurementsText
|
||
}.Where(value => !string.IsNullOrWhiteSpace(value))));
|
||
}
|
||
|
||
static bool ContainsAny(string text, params string[] needles)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(text))
|
||
return false;
|
||
|
||
return needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
|
||
static List<string> TokenizeForBm25(string text)
|
||
{
|
||
var terms = new List<string>();
|
||
var segment = new StringBuilder();
|
||
foreach (var ch in text.Replace('_', ' ').Replace('-', ' '))
|
||
{
|
||
if (char.IsLetterOrDigit(ch) || IsCjk(ch))
|
||
{
|
||
segment.Append(char.ToLowerInvariant(ch));
|
||
continue;
|
||
}
|
||
|
||
AddBm25SegmentTerms(terms, segment.ToString());
|
||
segment.Clear();
|
||
}
|
||
|
||
AddBm25SegmentTerms(terms, segment.ToString());
|
||
return terms.Where(term => !IsRetrievalStopToken(term)).ToList();
|
||
}
|
||
|
||
static void AddBm25SegmentTerms(List<string> terms, string segment)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(segment))
|
||
return;
|
||
|
||
segment = segment.Trim().ToLowerInvariant();
|
||
if (!segment.Any(IsCjk))
|
||
{
|
||
if (segment.Length >= 2)
|
||
terms.Add(segment);
|
||
return;
|
||
}
|
||
|
||
for (var n = 2; n <= 4; n++)
|
||
{
|
||
if (segment.Length < n)
|
||
continue;
|
||
for (var i = 0; i <= segment.Length - n; i++)
|
||
terms.Add(segment.Substring(i, n));
|
||
}
|
||
}
|
||
|
||
static bool IsRetrievalStopToken(string token)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(token))
|
||
return true;
|
||
|
||
var value = token.Trim().ToLowerInvariant();
|
||
return value is "and" or "or" or "the" or "with" or "for" or "candidate" or "type" or "role" or "face" or "part" or "local" or "shape" or
|
||
"一个" or "存在" or "之间" or "毫米" or "组件" or "零件" or "关系";
|
||
}
|
||
|
||
static MechanicalRuleCandidate ScoreRuleCandidate(JsonElement item, HashSet<string> queryTokens, MechanicalLocalUnit unit, JsonElement ruleDetail)
|
||
{
|
||
var ruleId = ReadString(item, "rule_id");
|
||
var primaryView = ReadString(item, "primary_view");
|
||
var secondaryViews = ReadStringArray(item, "secondary_views");
|
||
var retrieval = item.TryGetProperty("retrieval_unit", out var r) ? r : default;
|
||
var requiredModelFacts = ReadRuleRequiredModelFacts(ruleDetail);
|
||
var text = BuildRuleScoringText(item, ruleDetail, primaryView, secondaryViews, retrieval, requiredModelFacts);
|
||
var ruleTokens = Tokenize(text);
|
||
var textOverlap = Math.Min(10, queryTokens.Count == 0 ? 0 : queryTokens.Count(ruleTokens.Contains));
|
||
var scoreBreakdown = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["text_overlap"] = textOverlap
|
||
};
|
||
var score = textOverlap;
|
||
var viewMatched =
|
||
!string.IsNullOrWhiteSpace(unit.PrimaryView) &&
|
||
(string.Equals(unit.PrimaryView, primaryView, StringComparison.OrdinalIgnoreCase) ||
|
||
secondaryViews.Contains(unit.PrimaryView, StringComparer.OrdinalIgnoreCase));
|
||
var secondaryViewMatched = unit.SecondaryViews.Any(view =>
|
||
string.Equals(view, primaryView, StringComparison.OrdinalIgnoreCase) ||
|
||
secondaryViews.Contains(view, StringComparer.OrdinalIgnoreCase));
|
||
if (viewMatched)
|
||
{
|
||
score += 8;
|
||
scoreBreakdown["primary_view_match"] = 8;
|
||
}
|
||
if (secondaryViewMatched)
|
||
{
|
||
score += 4;
|
||
scoreBreakdown["secondary_view_match"] = 4;
|
||
}
|
||
if (!string.IsNullOrWhiteSpace(unit.PatternType) && text.Contains(unit.PatternType, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
score += 5;
|
||
scoreBreakdown["pattern_type_match"] = 5;
|
||
}
|
||
if (!string.IsNullOrWhiteSpace(unit.PatternType) && PatternMatchesRule(unit.PatternType, text))
|
||
{
|
||
score += 3;
|
||
scoreBreakdown["pattern_type_compatible"] = 3;
|
||
}
|
||
var factTagScore = Math.Min(12, CountSemanticMatches(unit.RetrievalFactTags, text, requiredModelFacts) * 3);
|
||
if (factTagScore > 0)
|
||
{
|
||
score += factTagScore;
|
||
scoreBreakdown["fact_tag_match"] = factTagScore;
|
||
}
|
||
var relationScore = Math.Min(9, CountSemanticMatches(unit.RetrievalRelations, text, requiredModelFacts) * 3);
|
||
if (relationScore > 0)
|
||
{
|
||
score += relationScore;
|
||
scoreBreakdown["relation_match"] = relationScore;
|
||
}
|
||
var inferredFunctionScore = Math.Min(6, CountSemanticMatches(unit.InferredFunctions, text, requiredModelFacts) * 2);
|
||
if (inferredFunctionScore > 0)
|
||
{
|
||
score += inferredFunctionScore;
|
||
scoreBreakdown["inferred_function_match"] = inferredFunctionScore;
|
||
}
|
||
var inferredAttributeScore = Math.Min(8, CountTokenOverlap(unit.InferredAttributesText, text) * 2);
|
||
if (inferredAttributeScore > 0)
|
||
{
|
||
score += inferredAttributeScore;
|
||
scoreBreakdown["inferred_attribute_match"] = inferredAttributeScore;
|
||
}
|
||
var measurementScore = Math.Min(6, CountTokenOverlap(unit.NormalizedMeasurementsText, text) * 2);
|
||
if (measurementScore > 0)
|
||
{
|
||
score += measurementScore;
|
||
scoreBreakdown["measurement_match"] = measurementScore;
|
||
}
|
||
var requiredFactOverlap = Math.Min(12, CountRequiredFactOverlap(requiredModelFacts, unit) * 3);
|
||
if (requiredFactOverlap > 0)
|
||
{
|
||
score += requiredFactOverlap;
|
||
scoreBreakdown["required_fact_overlap"] = requiredFactOverlap;
|
||
}
|
||
|
||
return new MechanicalRuleCandidate
|
||
{
|
||
RuleId = ruleId,
|
||
Code = ReadString(item, "code"),
|
||
Title = ReadString(item, "title"),
|
||
PrimaryView = primaryView,
|
||
Score = score,
|
||
ScoreBreakdown = scoreBreakdown,
|
||
ViewMatched = viewMatched || secondaryViewMatched,
|
||
IndexEntry = item.Clone(),
|
||
RuleDetail = ruleDetail.ValueKind == JsonValueKind.Undefined ? EmptyJsonObject() : ruleDetail.Clone()
|
||
};
|
||
}
|
||
|
||
static JsonElement EmptyJsonObject()
|
||
{
|
||
using var doc = JsonDocument.Parse("{}");
|
||
return doc.RootElement.Clone();
|
||
}
|
||
|
||
static string BuildRuleScoringText(
|
||
JsonElement item,
|
||
JsonElement ruleDetail,
|
||
string primaryView,
|
||
IReadOnlyList<string> secondaryViews,
|
||
JsonElement retrieval,
|
||
IReadOnlyList<string> requiredModelFacts)
|
||
{
|
||
var parts = new List<string>
|
||
{
|
||
ReadString(item, "title"),
|
||
ReadString(item, "knowledge_category"),
|
||
primaryView,
|
||
string.Join(' ', secondaryViews),
|
||
ReadString(retrieval, "retrieval_sentence"),
|
||
string.Join(' ', ReadStringArray(retrieval, "keywords")),
|
||
string.Join(' ', ReadStringArray(retrieval, "aliases")),
|
||
string.Join(' ', ReadStringArray(item, "pattern_types")),
|
||
string.Join(' ', ReadStringArray(item, "required_fact_tags")),
|
||
string.Join(' ', ReadStringArray(item, "trigger_relations")),
|
||
string.Join(' ', ReadStringArray(item, "bad_if_tags")),
|
||
string.Join(' ', ReadStringArray(item, "good_if_tags")),
|
||
string.Join(' ', requiredModelFacts)
|
||
};
|
||
|
||
if (ruleDetail.ValueKind == JsonValueKind.Object)
|
||
{
|
||
parts.Add(ReadString(ruleDetail, "title"));
|
||
parts.Add(ReadString(ruleDetail, "knowledge_category"));
|
||
if (ruleDetail.TryGetProperty("source_semantics", out var sourceSemantics))
|
||
parts.Add(ExtractRetrievalChainText(sourceSemantics));
|
||
if (ruleDetail.TryGetProperty("verification_rule", out var verificationRule))
|
||
parts.Add(ExtractRetrievalChainText(verificationRule));
|
||
}
|
||
|
||
return CleanKnowledgeText(string.Join(" ", parts.Where(value => !string.IsNullOrWhiteSpace(value))));
|
||
}
|
||
|
||
static List<string> ReadRuleRequiredModelFacts(JsonElement ruleDetail)
|
||
{
|
||
if (ruleDetail.ValueKind != JsonValueKind.Object ||
|
||
!ruleDetail.TryGetProperty("verification_rule", out var verificationRule) ||
|
||
verificationRule.ValueKind != JsonValueKind.Object)
|
||
{
|
||
return [];
|
||
}
|
||
|
||
return ReadStringArray(verificationRule, "required_model_facts")
|
||
.Concat(ReadStringArray(verificationRule, "required_facts"))
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
}
|
||
|
||
static int CountSemanticMatches(IEnumerable<string> values, string text, IReadOnlyList<string> requiredFacts)
|
||
{
|
||
var haystack = $"{text} {string.Join(' ', requiredFacts)}";
|
||
return values
|
||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||
.Select(CleanKnowledgeText)
|
||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.Count(value => haystack.Contains(value, StringComparison.OrdinalIgnoreCase) || Tokenize(value).Any(token => Tokenize(haystack).Contains(token)));
|
||
}
|
||
|
||
static int CountTokenOverlap(string left, string right)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right))
|
||
return 0;
|
||
|
||
var rightTokens = Tokenize(right);
|
||
return Tokenize(left).Count(rightTokens.Contains);
|
||
}
|
||
|
||
static int CountRequiredFactOverlap(IReadOnlyList<string> requiredFacts, MechanicalLocalUnit unit)
|
||
{
|
||
if (requiredFacts.Count == 0)
|
||
return 0;
|
||
|
||
var unitText = BuildKnowledgeRetrievalQuery(unit);
|
||
var unitTokens = Tokenize(unitText);
|
||
return requiredFacts
|
||
.SelectMany(fact => Tokenize(fact))
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.Count(unitTokens.Contains);
|
||
}
|
||
|
||
static bool IsRefinedRuleId(string ruleId)
|
||
{
|
||
return ruleId.StartsWith("18.", StringComparison.OrdinalIgnoreCase) ||
|
||
ruleId.StartsWith("19.", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
static HashSet<string> Tokenize(string text)
|
||
{
|
||
var tokens = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
if (Environment.TickCount >= int.MinValue)
|
||
{
|
||
AddSearchTokens(tokens, text);
|
||
return tokens;
|
||
}
|
||
text = text.Replace('_', ' ').Replace('-', ' ');
|
||
foreach (var token in text.Split(new[] { ' ', '\t', '\r', '\n', ',', '.', ';', ':', '/', '\\', '|', '[', ']', '(', ')', '{', '}', '"', '\'', ',', '。', ';', ':', '、' }, StringSplitOptions.RemoveEmptyEntries))
|
||
{
|
||
var t = token.Trim().ToLowerInvariant();
|
||
if (t.Length >= 2)
|
||
tokens.Add(t);
|
||
}
|
||
return tokens;
|
||
}
|
||
|
||
static void AddSearchTokens(HashSet<string> tokens, string text)
|
||
{
|
||
var segment = new StringBuilder();
|
||
foreach (var ch in text.Replace('_', ' ').Replace('-', ' '))
|
||
{
|
||
if (char.IsLetterOrDigit(ch) || IsCjk(ch))
|
||
{
|
||
segment.Append(char.ToLowerInvariant(ch));
|
||
continue;
|
||
}
|
||
|
||
AddSearchSegment(tokens, segment.ToString());
|
||
segment.Clear();
|
||
}
|
||
|
||
AddSearchSegment(tokens, segment.ToString());
|
||
}
|
||
|
||
static void AddSearchSegment(HashSet<string> tokens, string segment)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(segment))
|
||
return;
|
||
|
||
segment = segment.Trim().ToLowerInvariant();
|
||
if (segment.Length >= 2)
|
||
tokens.Add(segment);
|
||
|
||
if (!segment.Any(IsCjk))
|
||
return;
|
||
|
||
for (var n = 2; n <= 4; n++)
|
||
{
|
||
if (segment.Length < n)
|
||
continue;
|
||
for (var i = 0; i <= segment.Length - n; i++)
|
||
tokens.Add(segment.Substring(i, n));
|
||
}
|
||
}
|
||
|
||
static bool IsCjk(char ch)
|
||
{
|
||
return (ch >= '\u3400' && ch <= '\u4dbf') ||
|
||
(ch >= '\u4e00' && ch <= '\u9fff') ||
|
||
(ch >= '\uf900' && ch <= '\ufaff');
|
||
}
|
||
|
||
static string BuildLocalUnitSearchText(JsonElement element)
|
||
{
|
||
return string.Join(" ", new[]
|
||
{
|
||
ReadString(element, "pattern_type"),
|
||
ReadString(element, "primary_view"),
|
||
ReadString(element, "reason"),
|
||
string.Join(" ", ReadStringArray(element, "roles")),
|
||
string.Join(" ", ReadStringArray(element, "components")),
|
||
string.Join(" ", ReadStringArray(element, "missing_facts"))
|
||
}.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||
}
|
||
|
||
static string InferPrimaryView(string patternType)
|
||
{
|
||
var pattern = patternType.ToLowerInvariant();
|
||
if (pattern.Contains("machined") || pattern.Contains("unmachined") || pattern.Contains("surface") || pattern.Contains("boundary"))
|
||
return "surface_role_boundary";
|
||
if (pattern.Contains("boss") || pattern.Contains("register") || pattern.Contains("flange") || pattern.Contains("step"))
|
||
return "part_local_shape";
|
||
if (pattern.Contains("contact") || pattern.Contains("fit"))
|
||
return "assembly_contact_fit";
|
||
if (pattern.Contains("locating") || pattern.Contains("limit") || pattern.Contains("datum"))
|
||
return "locating_limit_datum";
|
||
if (pattern.Contains("disassembly") || pattern.Contains("maintenance") || pattern.Contains("access"))
|
||
return "disassembly_maintenance_access";
|
||
if (pattern.Contains("lubrication") || pattern.Contains("seal") || pattern.Contains("flow") || pattern.Contains("pipe") || pattern.Contains("drain") || pattern.Contains("air"))
|
||
return "lubrication_sealing_flow";
|
||
if (pattern.Contains("shaft") || pattern.Contains("bearing") || pattern.Contains("stress") || pattern.Contains("strength") || pattern.Contains("stiffness") || pattern.Contains("support"))
|
||
return "load_strength_stiffness";
|
||
if (pattern.Contains("hydraulic") || pattern.Contains("pneumatic") || pattern.Contains("safety"))
|
||
return "operation_safety_environment";
|
||
if (pattern.Contains("ergonomic") || pattern.Contains("environment") || pattern.Contains("thermal") || pattern.Contains("corrosion") || pattern.Contains("vibration") || pattern.Contains("noise"))
|
||
return "operation_safety_environment";
|
||
if (pattern.Contains("casting") || pattern.Contains("forging") || pattern.Contains("stamping") || pattern.Contains("welding") || pattern.Contains("powder") || pattern.Contains("plastic") || pattern.Contains("ceramic") || pattern.Contains("rubber") || pattern.Contains("adhesive") || pattern.Contains("heat"))
|
||
return "part_local_shape";
|
||
if (pattern.Contains("precision") || pattern.Contains("guideway"))
|
||
return "locating_limit_datum";
|
||
if (pattern.Contains("frame") || pattern.Contains("base"))
|
||
return "load_strength_stiffness";
|
||
return "";
|
||
}
|
||
|
||
static bool PatternMatchesRule(string patternType, string ruleText)
|
||
{
|
||
var pattern = patternType.ToLowerInvariant();
|
||
var text = ruleText.ToLowerInvariant();
|
||
if (pattern.Contains("machined") || pattern.Contains("unmachined") || pattern.Contains("surface"))
|
||
return text.Contains("surface_role_boundary") || text.Contains("part_local_shape");
|
||
if (pattern.Contains("boss") || pattern.Contains("register") || pattern.Contains("flange") || pattern.Contains("step"))
|
||
return text.Contains("part_local_shape") || text.Contains("surface_role_boundary") || text.Contains("locating_limit_datum");
|
||
if (pattern.Contains("contact") || pattern.Contains("fit") || pattern.Contains("mate") || pattern.Contains("assembly") || pattern.Contains("interface"))
|
||
return text.Contains("assembly_contact_fit") || text.Contains("disassembly_maintenance_access") || text.Contains("locating_limit_datum");
|
||
if (pattern.Contains("fastener") || pattern.Contains("screw") || pattern.Contains("bolt") || pattern.Contains("thread"))
|
||
return text.Contains("fastener_connection_design") || text.Contains("thread") || text.Contains("螺") || text.Contains("assembly_contact_fit");
|
||
if (pattern.Contains("pin") || pattern.Contains("dowel"))
|
||
return text.Contains("locating_pin_datum") || text.Contains("定位销") || text.Contains("销钉") || text.Contains("locating_limit_datum");
|
||
if (pattern.Contains("key") || pattern.Contains("spline") || pattern.Contains("keyway"))
|
||
return text.Contains("key_spline_connection") || text.Contains("键") || text.Contains("键槽");
|
||
if (pattern.Contains("interference") || pattern.Contains("press") || pattern.Contains("taper"))
|
||
return text.Contains("interference_fit_design") || text.Contains("过盈") || text.Contains("压入") || text.Contains("锥面");
|
||
if (pattern.Contains("shaft") || pattern.Contains("keyway"))
|
||
return text.Contains("shaft_") || text.Contains("轴") || text.Contains("键槽") || text.Contains("load_strength_stiffness");
|
||
if (pattern.Contains("bearing") || pattern.Contains("preload") || pattern.Contains("clearance"))
|
||
return text.Contains("bearing_") || text.Contains("轴承") || text.Contains("游隙") || text.Contains("预紧") || text.Contains("lubrication_sealing_flow");
|
||
if (pattern.Contains("lubrication") || pattern.Contains("oil") || pattern.Contains("grease"))
|
||
return text.Contains("bearing_lubrication_flow") || text.Contains("润滑") || text.Contains("油") || text.Contains("脂") || text.Contains("lubrication_sealing_flow");
|
||
if (pattern.Contains("seal") || pattern.Contains("gasket") || pattern.Contains("o_ring") || pattern.Contains("packing"))
|
||
return text.Contains("seal_") || text.Contains("packing_gland_design") || text.Contains("密封") || text.Contains("垫片") || text.Contains("填料") || text.Contains("lubrication_sealing_flow");
|
||
if (pattern.Contains("pipe") || pattern.Contains("tube") || pattern.Contains("hose") || pattern.Contains("drain") || pattern.Contains("air"))
|
||
return text.Contains("pipe_") || text.Contains("管") || text.Contains("软管") || text.Contains("排水") || text.Contains("排气") || text.Contains("lubrication_sealing_flow");
|
||
if (pattern.Contains("hydraulic") || pattern.Contains("pneumatic") || pattern.Contains("water_hammer") || pattern.Contains("safety"))
|
||
return text.Contains("hydraulic_pneumatic_safety_dynamics") || text.Contains("液压") || text.Contains("气动") || text.Contains("液击") || text.Contains("安全阀") || text.Contains("operation_safety_environment");
|
||
if (pattern.Contains("fatigue") || pattern.Contains("load_path") || pattern.Contains("strength") || pattern.Contains("stiffness"))
|
||
return text.Contains("force_strength_load_path") || text.Contains("fatigue_stress_surface") || text.Contains("载荷") || text.Contains("疲劳") || text.Contains("强度") || text.Contains("刚度") || text.Contains("load_strength_stiffness");
|
||
if (pattern.Contains("wear") || pattern.Contains("tribology"))
|
||
return text.Contains("lubrication_wear_tribology") || text.Contains("磨损") || text.Contains("摩擦") || text.Contains("润滑") || text.Contains("lubrication_sealing_flow");
|
||
if (pattern.Contains("precision") || pattern.Contains("error") || pattern.Contains("guideway"))
|
||
return text.Contains("precision_error_motion_chain") || text.Contains("guideway_precision_motion") || text.Contains("精度") || text.Contains("误差") || text.Contains("导轨") || text.Contains("locating_limit_datum");
|
||
if (pattern.Contains("ergonomic") || pattern.Contains("control") || pattern.Contains("environment") || pattern.Contains("thermal") || pattern.Contains("corrosion") || pattern.Contains("vibration") || pattern.Contains("noise"))
|
||
return text.Contains("ergonomics_control_safety") || text.Contains("environment_green_material") || text.Contains("thermal_corrosion_environment") || text.Contains("vibration_noise_isolation") || text.Contains("操作") || text.Contains("环境") || text.Contains("热") || text.Contains("腐蚀") || text.Contains("振动") || text.Contains("噪声") || text.Contains("operation_safety_environment");
|
||
if (pattern.Contains("casting") || pattern.Contains("forging") || pattern.Contains("stamping") || pattern.Contains("welding") || pattern.Contains("powder") || pattern.Contains("plastic") || pattern.Contains("ceramic") || pattern.Contains("rubber") || pattern.Contains("adhesive") || pattern.Contains("heat"))
|
||
return text.Contains("casting_mold_core_wall_rib") || text.Contains("forging_blank_simplification") || text.Contains("stamping_sheet_forming") || text.Contains("welding_joint_stress_deformation") || text.Contains("powder_pressing_shape") || text.Contains("plastic_molding_structure") || text.Contains("ceramic_rubber_nonmetal") || text.Contains("adhesive_joint_repair") || text.Contains("heat_surface_treatment") || text.Contains("铸") || text.Contains("锻") || text.Contains("冲压") || text.Contains("焊") || text.Contains("塑料") || text.Contains("陶瓷") || text.Contains("橡胶") || text.Contains("热处理") || text.Contains("part_local_shape");
|
||
if (pattern.Contains("frame") || pattern.Contains("base"))
|
||
return text.Contains("frame_base_stiffness") || text.Contains("机架") || text.Contains("底座") || text.Contains("支点") || text.Contains("刚度") || text.Contains("load_strength_stiffness");
|
||
return false;
|
||
}
|
||
|
||
static string RawJson(JsonElement element)
|
||
{
|
||
return element.ValueKind == JsonValueKind.Undefined ? "" : element.GetRawText();
|
||
}
|
||
|
||
static string ReadString(JsonElement element, string property)
|
||
{
|
||
return element.ValueKind == JsonValueKind.Object &&
|
||
element.TryGetProperty(property, out var value) &&
|
||
value.ValueKind == JsonValueKind.String
|
||
? value.GetString() ?? ""
|
||
: "";
|
||
}
|
||
|
||
static List<string> ReadStringArray(JsonElement element, string property)
|
||
{
|
||
return element.ValueKind == JsonValueKind.Object &&
|
||
element.TryGetProperty(property, out var value) &&
|
||
value.ValueKind == JsonValueKind.Array
|
||
? value.EnumerateArray().Select(x => x.GetString() ?? "").Where(x => x.Length > 0).ToList()
|
||
: [];
|
||
}
|
||
|
||
static List<JsonElement> ReadArray(JsonElement element, string property)
|
||
{
|
||
return element.ValueKind == JsonValueKind.Object &&
|
||
element.TryGetProperty(property, out var value) &&
|
||
value.ValueKind == JsonValueKind.Array
|
||
? value.EnumerateArray().Select(item => item.Clone()).ToList()
|
||
: [];
|
||
}
|
||
|
||
static int CountArray(JsonElement element, string property)
|
||
{
|
||
return element.ValueKind == JsonValueKind.Object &&
|
||
element.TryGetProperty(property, out var value) &&
|
||
value.ValueKind == JsonValueKind.Array
|
||
? value.GetArrayLength()
|
||
: 0;
|
||
}
|
||
|
||
static string FirstNonEmpty(params string[] values)
|
||
{
|
||
return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? "";
|
||
}
|
||
|
||
static string FirstExistingPath(params string[] paths)
|
||
{
|
||
return paths.FirstOrDefault(path => !string.IsNullOrWhiteSpace(path) && File.Exists(path)) ?? "";
|
||
}
|
||
|
||
static string FindString(JsonElement element, params string[] names)
|
||
{
|
||
if (element.ValueKind == JsonValueKind.Undefined || element.ValueKind == JsonValueKind.Null)
|
||
return "";
|
||
|
||
var targets = names.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||
return FindStringRecursive(element, targets);
|
||
}
|
||
|
||
static string FindStringRecursive(JsonElement element, HashSet<string> names)
|
||
{
|
||
if (element.ValueKind == JsonValueKind.Object)
|
||
{
|
||
foreach (var property in element.EnumerateObject())
|
||
{
|
||
if (names.Contains(property.Name) && property.Value.ValueKind == JsonValueKind.String)
|
||
return property.Value.GetString() ?? "";
|
||
|
||
var nested = FindStringRecursive(property.Value, names);
|
||
if (!string.IsNullOrWhiteSpace(nested))
|
||
return nested;
|
||
}
|
||
}
|
||
else if (element.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var item in element.EnumerateArray())
|
||
{
|
||
var nested = FindStringRecursive(item, names);
|
||
if (!string.IsNullOrWhiteSpace(nested))
|
||
return nested;
|
||
}
|
||
}
|
||
|
||
return "";
|
||
}
|
||
|
||
static bool ContainsJudgementWords(string value)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(value))
|
||
return false;
|
||
|
||
var text = value.ToLowerInvariant();
|
||
return text.Contains("error") ||
|
||
text.Contains("violation") ||
|
||
text.Contains("fault") ||
|
||
text.Contains("wrong") ||
|
||
text.Contains("should") ||
|
||
text.Contains("错误") ||
|
||
text.Contains("违反") ||
|
||
text.Contains("应该") ||
|
||
text.Contains("不应");
|
||
}
|
||
|
||
static string BuildKnowledgeRetrievalQuery(MechanicalLocalUnit unit)
|
||
{
|
||
return CleanKnowledgeText(string.Join(" ", new[]
|
||
{
|
||
unit.RetrievalSentence,
|
||
string.Join(" ", unit.RetrievalSemanticRoles),
|
||
string.Join(" ", unit.InferredFunctions),
|
||
string.Join(" ", unit.RetrievalFactTags),
|
||
unit.InferredAttributesText,
|
||
unit.NormalizedMeasurementsText,
|
||
string.Join(" ", unit.RetrievalRelations),
|
||
ExtractRetrievalChainText(unit.RetrievalChain)
|
||
}.Where(value => !string.IsNullOrWhiteSpace(value))));
|
||
}
|
||
|
||
static string ExtractObjectPropertyText(JsonElement element, string propertyName)
|
||
{
|
||
if (element.ValueKind != JsonValueKind.Object ||
|
||
!element.TryGetProperty(propertyName, out var property))
|
||
{
|
||
return "";
|
||
}
|
||
|
||
return ExtractRetrievalChainText(property);
|
||
}
|
||
|
||
static string ExtractRetrievalChainText(JsonElement element)
|
||
{
|
||
var values = new List<string>();
|
||
CollectSemanticStrings(element, values);
|
||
return string.Join(" ", values);
|
||
}
|
||
|
||
static void CollectSemanticStrings(JsonElement element, List<string> values)
|
||
{
|
||
switch (element.ValueKind)
|
||
{
|
||
case JsonValueKind.Object:
|
||
foreach (var property in element.EnumerateObject())
|
||
{
|
||
if (IsEvidenceIdField(property.Name))
|
||
continue;
|
||
CollectSemanticStrings(property.Value, values);
|
||
}
|
||
break;
|
||
case JsonValueKind.Array:
|
||
foreach (var item in element.EnumerateArray())
|
||
CollectSemanticStrings(item, values);
|
||
break;
|
||
case JsonValueKind.String:
|
||
var cleaned = CleanKnowledgeText(element.GetString() ?? "");
|
||
if (!string.IsNullOrWhiteSpace(cleaned) && !IsOnlyTechnicalReference(cleaned))
|
||
values.Add(cleaned);
|
||
break;
|
||
case JsonValueKind.Number:
|
||
values.Add(element.GetRawText());
|
||
break;
|
||
}
|
||
}
|
||
|
||
static bool IsEvidenceIdField(string fieldName)
|
||
{
|
||
var name = fieldName.ToLowerInvariant();
|
||
return name.Contains("id") ||
|
||
name.Contains("ref") ||
|
||
name.Contains("evidence") ||
|
||
name.Contains("source");
|
||
}
|
||
|
||
static bool ContainsTechnicalBrepReference(string value)
|
||
{
|
||
return TechnicalBrepReferenceRegex().IsMatch(value);
|
||
}
|
||
|
||
static bool IsOnlyTechnicalReference(string value)
|
||
{
|
||
var cleaned = CleanKnowledgeText(value);
|
||
return string.IsNullOrWhiteSpace(cleaned) || !cleaned.Any(ch => IsCjk(ch) || char.IsLetter(ch));
|
||
}
|
||
|
||
static string CleanKnowledgeText(string value)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(value))
|
||
return "";
|
||
|
||
var text = TechnicalBrepReferenceRegex().Replace(value, " ");
|
||
text = Regex.Replace(text, @"\b(?:SLDPRT|SLDASM|json|md)\b", " ", RegexOptions.IgnoreCase);
|
||
text = Regex.Replace(text, @"[A-Za-z]:\\[^\s,。;;]+", " ");
|
||
text = Regex.Replace(text, @"\s+", " ").Trim();
|
||
return text;
|
||
}
|
||
|
||
[GeneratedRegex(@"\b(?:sketch_edge|edge|face|vertex|patch|region|measurement|relation)_[A-Za-z0-9_.-]+\b|(?:sketch_edge|edge|face|vertex|patch|region|measurement|relation)_\d+", RegexOptions.IgnoreCase)]
|
||
private static partial Regex TechnicalBrepReferenceRegex();
|
||
|
||
static List<MechanicalEvidenceImage> CollectEvidenceImages(string outputDir)
|
||
{
|
||
if (!Directory.Exists(outputDir))
|
||
return [];
|
||
|
||
var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" };
|
||
var imageLabels = LoadEvidenceImageLabels(outputDir);
|
||
var imageMetadata = LoadEvidenceImageMetadata(outputDir);
|
||
return Directory.EnumerateFiles(outputDir, "*.*", SearchOption.AllDirectories)
|
||
.Where(path => allowed.Contains(Path.GetExtension(path)))
|
||
.Select(path => new FileInfo(path))
|
||
.Where(file => file.Exists && file.Length > 0)
|
||
.OrderBy(file => EvidenceImagePriority(file.FullName))
|
||
.ThenBy(file => file.FullName, StringComparer.OrdinalIgnoreCase)
|
||
.GroupBy(file => BuildEvidenceImageName(file.FullName, imageLabels), StringComparer.OrdinalIgnoreCase)
|
||
.Select(group => group.First())
|
||
.Take(200)
|
||
.Select(file =>
|
||
{
|
||
imageMetadata.TryGetValue(NormalizePathKey(file.FullName), out var metadata);
|
||
return new MechanicalEvidenceImage
|
||
{
|
||
Name = BuildEvidenceImageName(file.FullName, imageLabels),
|
||
Url = $"/api/mechanical-diagnostics/image?path={Uri.EscapeDataString(Convert.ToBase64String(Encoding.UTF8.GetBytes(file.FullName)))}",
|
||
FilePath = file.FullName,
|
||
ContentType = ContentTypeForImage(file.FullName),
|
||
ImageKind = FirstNonEmpty(metadata?.ImageKind ?? "", InferLegacyImageKind(file.FullName)),
|
||
EvidenceFamily = FirstNonEmpty(metadata?.EvidenceFamily ?? "", InferLegacyEvidenceFamily(file.FullName)),
|
||
EvidencePurpose = metadata?.EvidencePurpose ?? "",
|
||
PositionVisibility = metadata?.PositionVisibility ?? "",
|
||
BindingRefs = metadata?.BindingRefs ?? []
|
||
};
|
||
})
|
||
.ToList();
|
||
}
|
||
|
||
static Dictionary<string, MechanicalEvidenceImageMetadata> LoadEvidenceImageMetadata(string outputDir)
|
||
{
|
||
var result = new Dictionary<string, MechanicalEvidenceImageMetadata>(StringComparer.OrdinalIgnoreCase);
|
||
var reportPath = Path.Combine(outputDir, "extractions", "primary_y", "section_brep_report.json");
|
||
if (!File.Exists(reportPath))
|
||
return result;
|
||
|
||
try
|
||
{
|
||
using var doc = JsonDocument.Parse(File.ReadAllText(reportPath));
|
||
CollectImageMetadata(doc.RootElement, result);
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
static void CollectImageMetadata(JsonElement element, Dictionary<string, MechanicalEvidenceImageMetadata> result)
|
||
{
|
||
if (element.ValueKind == JsonValueKind.Object)
|
||
{
|
||
var outputPath = FirstNonEmpty(ReadString(element, "OutputPath"), ReadString(element, "output_path"));
|
||
var imageKind = FirstNonEmpty(ReadString(element, "ImageKind"), ReadString(element, "image_kind"));
|
||
var evidenceFamily = FirstNonEmpty(ReadString(element, "EvidenceFamily"), ReadString(element, "evidence_family"));
|
||
if (!string.IsNullOrWhiteSpace(outputPath) &&
|
||
(!string.IsNullOrWhiteSpace(imageKind) || !string.IsNullOrWhiteSpace(evidenceFamily)))
|
||
{
|
||
result[NormalizePathKey(outputPath)] = new MechanicalEvidenceImageMetadata
|
||
{
|
||
ImageKind = imageKind,
|
||
EvidenceFamily = evidenceFamily,
|
||
EvidencePurpose = FirstNonEmpty(ReadString(element, "EvidencePurpose"), ReadString(element, "evidence_purpose")),
|
||
PositionVisibility = FirstNonEmpty(ReadString(element, "PositionVisibility"), ReadString(element, "position_visibility")),
|
||
BindingRefs = CollectImageBindingRefs(element)
|
||
};
|
||
}
|
||
|
||
foreach (var property in element.EnumerateObject())
|
||
CollectImageMetadata(property.Value, result);
|
||
}
|
||
else if (element.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var item in element.EnumerateArray())
|
||
CollectImageMetadata(item, result);
|
||
}
|
||
}
|
||
|
||
static List<string> CollectImageBindingRefs(JsonElement element)
|
||
{
|
||
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var property in new[]
|
||
{
|
||
"HighlightInterfaceId", "highlight_interface_id",
|
||
"HighlightComponentId", "highlight_component_id",
|
||
"HighlightInterfaceComponentAId", "highlight_interface_component_a_id",
|
||
"HighlightInterfaceComponentBId", "highlight_interface_component_b_id",
|
||
"HighlightFeatureId", "highlight_feature_id",
|
||
"HighlightPlanId", "highlight_plan_id"
|
||
})
|
||
{
|
||
var value = ReadString(element, property);
|
||
if (!string.IsNullOrWhiteSpace(value))
|
||
result.Add(value);
|
||
}
|
||
foreach (var property in new[]
|
||
{
|
||
"HighlightInterfaceSourceContactIds", "highlight_interface_source_contact_ids",
|
||
"HighlightFaceRefs", "highlight_face_refs"
|
||
})
|
||
{
|
||
foreach (var value in ReadStringArray(element, property))
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(value))
|
||
result.Add(value);
|
||
}
|
||
}
|
||
return result.ToList();
|
||
}
|
||
|
||
static string InferLegacyEvidenceFamily(string path)
|
||
{
|
||
var normalized = path.Replace('\\', '/');
|
||
if (normalized.Contains("/physical_interfaces/", StringComparison.OrdinalIgnoreCase))
|
||
return "assembly_physical_interface";
|
||
if (normalized.Contains("/component_contexts/", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.Contains("/component_highlights/", StringComparison.OrdinalIgnoreCase))
|
||
return "assembly_component_position";
|
||
if (normalized.Contains("/functional_group_evidence/", StringComparison.OrdinalIgnoreCase) &&
|
||
normalized.Contains("/all_face_highlights/", StringComparison.OrdinalIgnoreCase))
|
||
return "part_all_face_surface";
|
||
if (normalized.Contains("/feature_highlights/", StringComparison.OrdinalIgnoreCase))
|
||
return "part_feature_surface";
|
||
return "";
|
||
}
|
||
|
||
static string InferLegacyImageKind(string path)
|
||
{
|
||
var normalized = path.Replace('\\', '/');
|
||
if (normalized.Contains("/physical_interfaces/", StringComparison.OrdinalIgnoreCase))
|
||
return "assembly_physical_interface_view";
|
||
if (normalized.Contains("/component_contexts/", StringComparison.OrdinalIgnoreCase))
|
||
return "assembly_component_context_isolate_view";
|
||
if (normalized.Contains("/component_highlights/", StringComparison.OrdinalIgnoreCase))
|
||
return "assembly_component_highlight_view";
|
||
if (normalized.Contains("/functional_group_evidence/", StringComparison.OrdinalIgnoreCase) &&
|
||
normalized.Contains("/all_face_highlights/", StringComparison.OrdinalIgnoreCase))
|
||
return "functional_group_all_face_highlight";
|
||
if (normalized.Contains("/feature_highlights/", StringComparison.OrdinalIgnoreCase))
|
||
return "part_feature_highlight_view";
|
||
return "";
|
||
}
|
||
|
||
static int EvidenceImagePriority(string path)
|
||
{
|
||
var lower = path.ToLowerInvariant();
|
||
var name = Path.GetFileName(lower);
|
||
if (lower.Contains("component_highlights")) return 0;
|
||
if (lower.Contains("component_contexts")) return 1;
|
||
if (lower.Contains("part_images")) return 2;
|
||
if (lower.Contains("section")) return 3;
|
||
if (name.Contains("isometric") || name.Contains("iso")) return 4;
|
||
if (name.Contains("front") || name.Contains("top") || name.Contains("right")) return 5;
|
||
return 6;
|
||
}
|
||
|
||
static Dictionary<string, List<string>> LoadEvidenceImageLabels(string outputDir)
|
||
{
|
||
var reportPath = Path.Combine(outputDir, "extractions", "primary_y", "section_brep_report.json");
|
||
if (!File.Exists(reportPath))
|
||
return new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||
|
||
try
|
||
{
|
||
using var doc = JsonDocument.Parse(File.ReadAllText(reportPath));
|
||
var labels = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||
CollectImageLabels(doc.RootElement, labels);
|
||
return labels;
|
||
}
|
||
catch
|
||
{
|
||
return new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||
}
|
||
}
|
||
|
||
static void CollectImageLabels(JsonElement element, Dictionary<string, List<string>> labels)
|
||
{
|
||
if (element.ValueKind == JsonValueKind.Object)
|
||
{
|
||
var outputPath = FirstNonEmpty(
|
||
ReadString(element, "OutputPath"),
|
||
ReadString(element, "output_path"),
|
||
ReadString(element, "ImagePath"),
|
||
ReadString(element, "image_path"));
|
||
if (!string.IsNullOrWhiteSpace(outputPath))
|
||
{
|
||
var key = NormalizePathKey(outputPath);
|
||
var values = new[]
|
||
{
|
||
ReadString(element, "HighlightComponentDisplayName"),
|
||
ReadString(element, "HighlightComponentInstanceName"),
|
||
ReadString(element, "ComponentDisplayName"),
|
||
ReadString(element, "ComponentName"),
|
||
ReadString(element, "ComponentFileName"),
|
||
ReadString(element, "InstanceName"),
|
||
ReadString(element, "PartFileName")
|
||
}
|
||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
|
||
if (values.Count > 0)
|
||
{
|
||
if (!labels.TryGetValue(key, out var existing))
|
||
{
|
||
existing = [];
|
||
labels[key] = existing;
|
||
}
|
||
foreach (var value in values)
|
||
{
|
||
if (!existing.Contains(value, StringComparer.OrdinalIgnoreCase))
|
||
existing.Add(value);
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach (var property in element.EnumerateObject())
|
||
CollectImageLabels(property.Value, labels);
|
||
}
|
||
else if (element.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var item in element.EnumerateArray())
|
||
CollectImageLabels(item, labels);
|
||
}
|
||
}
|
||
|
||
static string NormalizePathKey(string path)
|
||
{
|
||
try
|
||
{
|
||
return Path.GetFullPath(path).Trim().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||
}
|
||
catch
|
||
{
|
||
return path.Trim();
|
||
}
|
||
}
|
||
|
||
static string BuildEvidenceImageName(string path, IReadOnlyDictionary<string, List<string>> imageLabels)
|
||
{
|
||
var file = Path.GetFileNameWithoutExtension(path);
|
||
var parent = Path.GetFileName(Path.GetDirectoryName(path) ?? "");
|
||
var grandParent = Path.GetFileName(Path.GetDirectoryName(Path.GetDirectoryName(path) ?? "") ?? "");
|
||
var scopedName = string.IsNullOrWhiteSpace(parent) ? file : $"{parent}/{file}";
|
||
var labelSuffix = imageLabels.TryGetValue(NormalizePathKey(path), out var labels) && labels.Count > 0
|
||
? $"({string.Join(";", labels.Take(3))})"
|
||
: "";
|
||
if (path.Contains("component_contexts", StringComparison.OrdinalIgnoreCase))
|
||
return $"组件上下文截图:{scopedName}{labelSuffix}";
|
||
if (path.Contains("component_highlights", StringComparison.OrdinalIgnoreCase))
|
||
return $"装配体高亮截图:{scopedName}{labelSuffix}";
|
||
if (path.Replace('\\', '/').Contains("functional_group_evidence/", StringComparison.OrdinalIgnoreCase) &&
|
||
path.Replace('\\', '/').Contains("/all_face_highlights/", StringComparison.OrdinalIgnoreCase))
|
||
return $"功能组拆分后零件全脸高亮图:{grandParent}/{scopedName}{labelSuffix}";
|
||
if (path.Contains("functional_group_evidence", StringComparison.OrdinalIgnoreCase))
|
||
return $"功能组接触配合证据图:{grandParent}/{scopedName}{labelSuffix}";
|
||
if (path.Contains("part_images", StringComparison.OrdinalIgnoreCase))
|
||
return $"零件图截图:{scopedName}{labelSuffix}";
|
||
if (file.Contains("isometric", StringComparison.OrdinalIgnoreCase) || file.Contains("iso", StringComparison.OrdinalIgnoreCase))
|
||
return $"等轴测截图:{grandParent}/{scopedName}";
|
||
return $"模型截图:{grandParent}/{scopedName}";
|
||
}
|
||
|
||
static string ContentTypeForImage(string path) =>
|
||
Path.GetExtension(path).ToLowerInvariant() switch
|
||
{
|
||
".jpg" or ".jpeg" => "image/jpeg",
|
||
".png" => "image/png",
|
||
".webp" => "image/webp",
|
||
_ => "application/octet-stream"
|
||
};
|
||
|
||
static List<MechanicalRetrievedIssue> BuildRetrievedIssues(
|
||
MechanicalKnowledgeRetrievalResult retrieval,
|
||
IReadOnlyDictionary<string, List<MechanicalEvidenceImage>> evidenceImagesByUnit)
|
||
{
|
||
return retrieval.Units
|
||
.SelectMany(unit => unit.Candidates.Select(candidate => new { Unit = unit, Candidate = candidate }))
|
||
.Where(item => !string.IsNullOrWhiteSpace(FirstNonEmpty(item.Candidate.RuleId, item.Candidate.Code)))
|
||
.GroupBy(item => $"{item.Unit.UnitKey}\u001f{FirstNonEmpty(item.Candidate.RuleId, item.Candidate.Code)}", StringComparer.OrdinalIgnoreCase)
|
||
.Select(group => group
|
||
.OrderByDescending(item => item.Candidate.Score)
|
||
.ThenBy(item => item.Unit.UnitKey, StringComparer.OrdinalIgnoreCase)
|
||
.First())
|
||
.OrderByDescending(item => item.Candidate.Score)
|
||
.ThenBy(item => FirstNonEmpty(item.Candidate.RuleId, item.Candidate.Code), StringComparer.OrdinalIgnoreCase)
|
||
.Select(item => new MechanicalRetrievedIssue
|
||
{
|
||
UnitKey = item.Unit.UnitKey,
|
||
ComponentId = item.Unit.ComponentId,
|
||
InstanceName = item.Unit.InstanceName,
|
||
LocalId = item.Unit.LocalId,
|
||
RetrievalSentence = item.Unit.RetrievalSentence,
|
||
AnchorType = item.Unit.AnchorType,
|
||
AnchorId = item.Unit.AnchorId,
|
||
PatternType = item.Unit.PatternType,
|
||
RuleId = FirstNonEmpty(item.Candidate.RuleId, item.Candidate.Code),
|
||
RuleTitle = item.Candidate.Title,
|
||
PrimaryView = item.Candidate.PrimaryView,
|
||
Score = item.Candidate.Score,
|
||
ImageEvidenceNames = item.Unit.ImageEvidenceNames,
|
||
EvidenceImages = evidenceImagesByUnit.TryGetValue(item.Unit.UnitKey, out var unitImages)
|
||
? unitImages
|
||
: []
|
||
})
|
||
.ToList();
|
||
}
|
||
|
||
static List<MechanicalFinalIssue> BuildFinalIssues(
|
||
string finalReportMarkdown,
|
||
IReadOnlyList<MechanicalRetrievedIssue> retrievedIssues)
|
||
{
|
||
var blocks = SplitFinalIssueBlocks(finalReportMarkdown);
|
||
if (blocks.Count == 0)
|
||
return [];
|
||
|
||
var uniqueUnits = retrievedIssues
|
||
.Where(issue => !string.IsNullOrWhiteSpace(issue.UnitKey))
|
||
.GroupBy(issue => issue.UnitKey, StringComparer.OrdinalIgnoreCase)
|
||
.Select(group => group
|
||
.OrderByDescending(issue => issue.Score)
|
||
.ThenBy(issue => issue.RuleId, StringComparer.OrdinalIgnoreCase)
|
||
.First())
|
||
.ToList();
|
||
|
||
var result = new List<MechanicalFinalIssue>();
|
||
foreach (var block in blocks)
|
||
{
|
||
var judgement = ReadFinalIssueLine(block, "判定");
|
||
var taboo = ReadFinalIssueLine(block, "对应禁忌");
|
||
var location = ReadFinalIssueLine(block, "问题部位");
|
||
var reason = ReadFinalIssueLine(block, "为什么错");
|
||
var suggestion = ReadFinalIssueLine(block, "建议");
|
||
var ruleId = ExtractRuleId(taboo);
|
||
if (string.IsNullOrWhiteSpace(ruleId))
|
||
continue;
|
||
|
||
var relatedUnits = SelectRelatedUnitsForFinalIssue(block, ruleId, uniqueUnits);
|
||
var evidenceImages = BuildFinalIssueEvidenceImages(relatedUnits);
|
||
result.Add(new MechanicalFinalIssue
|
||
{
|
||
Judgement = judgement,
|
||
RuleId = ruleId,
|
||
RuleTitle = taboo.Replace(ruleId, "", StringComparison.OrdinalIgnoreCase).Trim(' ', '-', ':', ':'),
|
||
Location = location,
|
||
Reason = reason,
|
||
Suggestion = suggestion,
|
||
ReportBlockMarkdown = block.Trim(),
|
||
RelatedUnitKeys = relatedUnits.Select(unit => unit.UnitKey).Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
|
||
RelatedAnchorIds = relatedUnits.Select(unit => unit.AnchorId).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
|
||
EvidenceImages = evidenceImages
|
||
});
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
static List<string> SplitFinalIssueBlocks(string report)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(report))
|
||
return [];
|
||
|
||
var normalized = report.Replace("\r\n", "\n").Replace('\r', '\n');
|
||
return Regex.Matches(normalized, @"(?ms)^\s*-\s*判定[::].*?(?=^\s*-\s*判定[::]|\z)")
|
||
.Select(match => match.Value.Trim())
|
||
.Where(block => !string.IsNullOrWhiteSpace(ExtractRuleId(ReadFinalIssueLine(block, "对应禁忌"))))
|
||
.ToList();
|
||
}
|
||
|
||
static string ReadFinalIssueLine(string block, string label)
|
||
{
|
||
var match = Regex.Match(block, $@"(?m)^\s*-\s*{Regex.Escape(label)}[::]\s*(.+?)\s*$");
|
||
return match.Success ? match.Groups[1].Value.Trim() : "";
|
||
}
|
||
|
||
static string ExtractRuleId(string text)
|
||
{
|
||
var match = Regex.Match(text ?? "", @"18\.\d+(?:\.\d+)*|19\.\d+(?:\.\d+)*");
|
||
return match.Success ? match.Value : "";
|
||
}
|
||
|
||
static List<MechanicalRetrievedIssue> SelectRelatedUnitsForFinalIssue(
|
||
string block,
|
||
string ruleId,
|
||
IReadOnlyList<MechanicalRetrievedIssue> units)
|
||
{
|
||
var scored = units
|
||
.Select(unit => new
|
||
{
|
||
Unit = unit,
|
||
Score = ScoreUnitForFinalIssue(block, ruleId, unit)
|
||
})
|
||
.Where(item => item.Score >= 50)
|
||
.OrderByDescending(item => item.Score)
|
||
.ThenBy(item => item.Unit.AnchorId, StringComparer.OrdinalIgnoreCase)
|
||
.Select(item => item.Unit)
|
||
.ToList();
|
||
|
||
if (scored.Count > 0)
|
||
return scored;
|
||
|
||
return units
|
||
.Where(unit => unit.RuleId.Equals(ruleId, StringComparison.OrdinalIgnoreCase))
|
||
.OrderByDescending(unit => unit.Score)
|
||
.Take(1)
|
||
.ToList();
|
||
}
|
||
|
||
static int ScoreUnitForFinalIssue(string block, string ruleId, MechanicalRetrievedIssue unit)
|
||
{
|
||
var text = NormalizeFinalIssueMatchText(block);
|
||
var score = unit.RuleId.Equals(ruleId, StringComparison.OrdinalIgnoreCase) ? 20 : 0;
|
||
|
||
foreach (var token in FinalIssueUnitTokens(unit))
|
||
{
|
||
if (string.IsNullOrWhiteSpace(token))
|
||
continue;
|
||
var normalizedToken = NormalizeFinalIssueMatchText(token);
|
||
if (normalizedToken.Length < 3)
|
||
continue;
|
||
if (text.Contains(normalizedToken, StringComparison.OrdinalIgnoreCase))
|
||
score += token.Equals(unit.AnchorId, StringComparison.OrdinalIgnoreCase) ? 180 : 90;
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(unit.AnchorId) && AnchorIdIsInFinalIssueRange(text, unit.AnchorId))
|
||
score += 180;
|
||
|
||
var blockDiameters = ExtractMillimeterValues(block);
|
||
var unitDiameters = ExtractMillimeterValues(unit.RetrievalSentence);
|
||
if (blockDiameters.Count > 0 && unitDiameters.Any(unitValue => blockDiameters.Any(blockValue => Math.Abs(blockValue - unitValue) <= 0.001)))
|
||
score += 120;
|
||
else if (blockDiameters.Count > 0 && unitDiameters.Count > 0)
|
||
score -= 120;
|
||
|
||
score += SemanticFamilyScore(text, unit);
|
||
return score;
|
||
}
|
||
|
||
static IEnumerable<string> FinalIssueUnitTokens(MechanicalRetrievedIssue unit)
|
||
{
|
||
yield return unit.UnitKey;
|
||
yield return unit.LocalId;
|
||
yield return unit.AnchorId;
|
||
yield return unit.AnchorType;
|
||
yield return unit.PatternType;
|
||
foreach (var name in unit.ImageEvidenceNames)
|
||
yield return name;
|
||
}
|
||
|
||
static bool AnchorIdIsInFinalIssueRange(string normalizedBlock, string anchorId)
|
||
{
|
||
var anchor = NormalizeFinalIssueMatchText(anchorId);
|
||
var anchorMatch = Regex.Match(anchor, @"^(?<prefix>[a-z_]+)_(?<number>\d+)$");
|
||
if (!anchorMatch.Success)
|
||
return false;
|
||
|
||
var prefix = Regex.Escape(anchorMatch.Groups["prefix"].Value);
|
||
var number = int.Parse(anchorMatch.Groups["number"].Value, CultureInfo.InvariantCulture);
|
||
foreach (Match range in Regex.Matches(normalizedBlock, $@"{prefix}_(?<start>\d+)\s*(?:至|到|~|-)\s*(?:{prefix}_)?(?<end>\d+)"))
|
||
{
|
||
var start = int.Parse(range.Groups["start"].Value, CultureInfo.InvariantCulture);
|
||
var end = int.Parse(range.Groups["end"].Value, CultureInfo.InvariantCulture);
|
||
if (number >= Math.Min(start, end) && number <= Math.Max(start, end))
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
static int SemanticFamilyScore(string normalizedBlock, MechanicalRetrievedIssue unit)
|
||
{
|
||
var unitText = NormalizeFinalIssueMatchText($"{unit.AnchorId} {unit.AnchorType} {unit.PatternType} {unit.RetrievalSentence}");
|
||
var score = 0;
|
||
if ((normalizedBlock.Contains("孔组", StringComparison.OrdinalIgnoreCase) || normalizedBlock.Contains("hole", StringComparison.OrdinalIgnoreCase)) &&
|
||
(unitText.Contains("孔", StringComparison.OrdinalIgnoreCase) || unitText.Contains("hole", StringComparison.OrdinalIgnoreCase)))
|
||
score += 55;
|
||
if ((normalizedBlock.Contains("平面候选", StringComparison.OrdinalIgnoreCase) || normalizedBlock.Contains("planar", StringComparison.OrdinalIgnoreCase)) &&
|
||
(unitText.Contains("平面", StringComparison.OrdinalIgnoreCase) || unitText.Contains("planar", StringComparison.OrdinalIgnoreCase)))
|
||
score += 55;
|
||
if ((normalizedBlock.Contains("圆柱面组", StringComparison.OrdinalIgnoreCase) || normalizedBlock.Contains("cylindrical", StringComparison.OrdinalIgnoreCase)) &&
|
||
(unitText.Contains("圆柱", StringComparison.OrdinalIgnoreCase) || unitText.Contains("cylindrical", StringComparison.OrdinalIgnoreCase)))
|
||
score += 55;
|
||
if ((normalizedBlock.Contains("复杂曲面", StringComparison.OrdinalIgnoreCase) || normalizedBlock.Contains("complex", StringComparison.OrdinalIgnoreCase)) &&
|
||
(unitText.Contains("复杂", StringComparison.OrdinalIgnoreCase) || unitText.Contains("complex", StringComparison.OrdinalIgnoreCase)))
|
||
score += 55;
|
||
return score;
|
||
}
|
||
|
||
static string NormalizeFinalIssueMatchText(string value) =>
|
||
(value ?? "")
|
||
.Replace('\\', '/')
|
||
.Replace("模型截图:", "", StringComparison.OrdinalIgnoreCase)
|
||
.Replace("功能组拆分后零件全脸高亮图:", "", StringComparison.OrdinalIgnoreCase)
|
||
.Replace("功能组接触配合证据图:", "", StringComparison.OrdinalIgnoreCase)
|
||
.Trim()
|
||
.ToLowerInvariant();
|
||
|
||
static List<double> ExtractMillimeterValues(string text) =>
|
||
Regex.Matches(text ?? "", @"(?<![\d.])(?<value>\d+(?:\.\d+)?)\s*(?:mm|毫米)")
|
||
.Select(match => double.Parse(match.Groups["value"].Value, CultureInfo.InvariantCulture))
|
||
.Distinct()
|
||
.ToList();
|
||
|
||
static List<MechanicalEvidenceImage> BuildFinalIssueEvidenceImages(IReadOnlyList<MechanicalRetrievedIssue> relatedUnits)
|
||
{
|
||
var result = new List<MechanicalEvidenceImage>();
|
||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var unit in relatedUnits)
|
||
{
|
||
var image = unit.EvidenceImages.FirstOrDefault();
|
||
if (image == null)
|
||
continue;
|
||
var key = FirstNonEmpty(image.Url, image.FilePath, image.Name);
|
||
if (!seen.Add($"{unit.UnitKey}\u001f{key}"))
|
||
continue;
|
||
result.Add(CloneEvidenceImageForFinalIssue(image, unit));
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
static MechanicalEvidenceImage CloneEvidenceImageForFinalIssue(MechanicalEvidenceImage image, MechanicalRetrievedIssue unit) =>
|
||
new()
|
||
{
|
||
Name = image.Name,
|
||
Url = image.Url,
|
||
ImageKind = image.ImageKind,
|
||
EvidenceFamily = image.EvidenceFamily,
|
||
EvidencePurpose = image.EvidencePurpose,
|
||
PositionVisibility = image.PositionVisibility,
|
||
SourceUnitKey = unit.UnitKey,
|
||
SourceLocalId = unit.LocalId,
|
||
SourceAnchorId = unit.AnchorId,
|
||
FilePath = image.FilePath,
|
||
ContentType = image.ContentType,
|
||
BindingRefs = image.BindingRefs
|
||
};
|
||
|
||
static Dictionary<string, List<MechanicalEvidenceImage>> BuildEvidenceImagesByUnit(
|
||
MechanicalKnowledgeRetrievalResult retrieval,
|
||
IReadOnlyList<MechanicalEvidenceImage> images,
|
||
int maxImages)
|
||
{
|
||
var result = new Dictionary<string, List<MechanicalEvidenceImage>>(StringComparer.OrdinalIgnoreCase);
|
||
if (maxImages <= 0 || images.Count == 0)
|
||
return result;
|
||
|
||
foreach (var unit in retrieval.Units)
|
||
{
|
||
var imagePool = FilterImagesForDiagnosticStage(images, unit).ToList();
|
||
if (imagePool.Count == 0)
|
||
continue;
|
||
var explicitImages = SelectExplicitEvidenceImages(unit, imagePool, maxImages);
|
||
if (explicitImages.Count > 0)
|
||
{
|
||
result[unit.UnitKey] = explicitImages;
|
||
continue;
|
||
}
|
||
|
||
var text = BuildUnitSearchText(unit);
|
||
var scored = imagePool
|
||
.Select(image => new
|
||
{
|
||
Image = image,
|
||
Score = ScoreImageForUnit(text, image),
|
||
Priority = EvidenceImagePriority(image.FilePath)
|
||
})
|
||
.OrderByDescending(item => item.Score)
|
||
.ThenBy(item => item.Priority)
|
||
.ThenBy(item => item.Image.Name, StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
|
||
var selected = scored
|
||
.Where(item => item.Score > 0)
|
||
.Select(item => item.Image)
|
||
.DistinctBy(image => FirstNonEmpty(image.FilePath, image.Url), StringComparer.OrdinalIgnoreCase)
|
||
.Take(maxImages)
|
||
.ToList();
|
||
|
||
if (selected.Count > 0 && !string.IsNullOrWhiteSpace(unit.UnitKey))
|
||
{
|
||
result[unit.UnitKey] = selected;
|
||
continue;
|
||
}
|
||
|
||
var relatedComponentIds = ExtractRelatedComponentIds(unit);
|
||
var relatedImages = imagePool
|
||
.Where(image => ImageBelongsToAnyComponent(image, relatedComponentIds))
|
||
.OrderBy(image => EvidenceImagePriority(image.FilePath))
|
||
.ThenBy(image => image.Name, StringComparer.OrdinalIgnoreCase)
|
||
.DistinctBy(image => FirstNonEmpty(image.FilePath, image.Url), StringComparer.OrdinalIgnoreCase)
|
||
.Take(maxImages)
|
||
.ToList();
|
||
if (relatedImages.Count > 0 && !string.IsNullOrWhiteSpace(unit.UnitKey))
|
||
{
|
||
result[unit.UnitKey] = relatedImages;
|
||
continue;
|
||
}
|
||
|
||
selected = scored
|
||
.Select(item => item.Image)
|
||
.DistinctBy(image => FirstNonEmpty(image.FilePath, image.Url), StringComparer.OrdinalIgnoreCase)
|
||
.Take(Math.Min(maxImages, 1))
|
||
.ToList();
|
||
|
||
if (selected.Count > 0 && !string.IsNullOrWhiteSpace(unit.UnitKey))
|
||
result[unit.UnitKey] = selected;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
static IEnumerable<MechanicalEvidenceImage> FilterImagesForDiagnosticStage(
|
||
IReadOnlyList<MechanicalEvidenceImage> images,
|
||
MechanicalRetrievedUnit unit)
|
||
{
|
||
if (IsFunctionalGroupKnowledgeUnit(unit.AnchorType) ||
|
||
unit.DiagnosticStage.Equals(MechanicalDiagnosticFlowTerms.StageFunctionalGroupCheck, StringComparison.OrdinalIgnoreCase) ||
|
||
unit.KnowledgeScope.Equals(MechanicalDiagnosticFlowTerms.KnowledgeScopeAssembly, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return images.Where(IsFunctionalGroupEvidenceImage);
|
||
}
|
||
|
||
if (unit.DiagnosticStage.Equals(MechanicalDiagnosticFlowTerms.StageSinglePartCheck, StringComparison.OrdinalIgnoreCase))
|
||
return images.Where(IsSinglePartEvidenceImage);
|
||
|
||
return images.Where(IsGroupInternalPartEvidenceImage);
|
||
}
|
||
|
||
static bool IsFunctionalGroupEvidenceImage(MechanicalEvidenceImage image)
|
||
{
|
||
return image.EvidenceFamily.Equals("assembly_physical_interface", StringComparison.OrdinalIgnoreCase) ||
|
||
string.IsNullOrWhiteSpace(image.EvidenceFamily) &&
|
||
InferLegacyEvidenceFamily(image.FilePath).Equals("assembly_physical_interface", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
static bool IsGroupInternalPartEvidenceImage(MechanicalEvidenceImage image)
|
||
{
|
||
return image.EvidenceFamily.Equals("part_all_face_surface", StringComparison.OrdinalIgnoreCase) ||
|
||
string.IsNullOrWhiteSpace(image.EvidenceFamily) &&
|
||
InferLegacyEvidenceFamily(image.FilePath).Equals("part_all_face_surface", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
static bool IsSinglePartEvidenceImage(MechanicalEvidenceImage image)
|
||
{
|
||
return image.EvidenceFamily.Equals("part_feature_surface", StringComparison.OrdinalIgnoreCase) ||
|
||
image.ImageKind.Equals("part_feature_highlight_view", StringComparison.OrdinalIgnoreCase) ||
|
||
string.IsNullOrWhiteSpace(image.EvidenceFamily) &&
|
||
InferLegacyEvidenceFamily(image.FilePath).Equals("part_feature_surface", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
static string BuildUnitSearchText(MechanicalRetrievedUnit unit) =>
|
||
$"{unit.UnitKey} {unit.ComponentId} {unit.InstanceName} {unit.LocalId} {unit.RetrievalSentence} {unit.AnchorType} {unit.AnchorId} {unit.PrimaryView} {unit.PatternType} {string.Join(" ", unit.ImageEvidenceNames)} {RawJson(unit.FactBundle)}".ToLowerInvariant();
|
||
|
||
static List<MechanicalEvidenceImage> SelectExplicitEvidenceImages(
|
||
MechanicalRetrievedUnit unit,
|
||
IReadOnlyList<MechanicalEvidenceImage> images,
|
||
int maxImages)
|
||
{
|
||
var explicitKeys = BuildExplicitImageEvidenceKeys(unit);
|
||
if (explicitKeys.Count == 0)
|
||
return [];
|
||
|
||
return images
|
||
.Where(image => ImageEvidenceKeys(image).Any(explicitKeys.Contains))
|
||
.OrderBy(image => EvidenceImagePriority(image.FilePath))
|
||
.ThenBy(image => image.Name, StringComparer.OrdinalIgnoreCase)
|
||
.DistinctBy(image => FirstNonEmpty(image.FilePath, image.Url), StringComparer.OrdinalIgnoreCase)
|
||
.Take(maxImages)
|
||
.ToList();
|
||
}
|
||
|
||
static HashSet<string> BuildExplicitImageEvidenceKeys(MechanicalRetrievedUnit unit)
|
||
{
|
||
var values = unit.ImageEvidenceNames
|
||
.Concat(ReadStringArray(unit.FactBundle, "image_evidence_names"))
|
||
.SelectMany(ImageEvidenceNameKeys)
|
||
.Where(value => value.Length >= 3)
|
||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||
|
||
return values;
|
||
}
|
||
|
||
static IEnumerable<string> ImageEvidenceNameKeys(string value)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(value))
|
||
yield break;
|
||
|
||
var normalized = value.Replace('\\', '/').Trim().ToLowerInvariant();
|
||
normalized = normalized
|
||
.Replace("模型截图:", "", StringComparison.OrdinalIgnoreCase)
|
||
.Replace("功能组拆分后零件全脸高亮图:", "", StringComparison.OrdinalIgnoreCase)
|
||
.Replace("功能组接触配合证据图:", "", StringComparison.OrdinalIgnoreCase)
|
||
.Replace("组件上下文截图:", "", StringComparison.OrdinalIgnoreCase)
|
||
.Replace("装配体高亮截图:", "", StringComparison.OrdinalIgnoreCase)
|
||
.Replace("零件图截图:", "", StringComparison.OrdinalIgnoreCase)
|
||
.Replace("等轴测截图:", "", StringComparison.OrdinalIgnoreCase);
|
||
yield return normalized;
|
||
yield return Path.ChangeExtension(normalized, null) ?? normalized;
|
||
yield return Path.GetFileNameWithoutExtension(normalized);
|
||
|
||
foreach (var marker in new[]
|
||
{
|
||
"functional_group_evidence/", "all_face_highlights/", "feature_highlights/",
|
||
"physical_interfaces/", "component_contexts/", "component_highlights/", "part_images/", "model_images/"
|
||
})
|
||
{
|
||
var index = normalized.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||
if (index < 0)
|
||
continue;
|
||
|
||
var relative = normalized[index..];
|
||
yield return relative;
|
||
yield return Path.ChangeExtension(relative, null) ?? relative;
|
||
}
|
||
}
|
||
|
||
static HashSet<string> ExtractRelatedComponentIds(MechanicalRetrievedUnit unit)
|
||
{
|
||
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
AddComponentIdsFromText(unit.ComponentId, ids);
|
||
AddComponentIdsFromText(unit.UnitKey, ids);
|
||
AddComponentIdsFromText(unit.LocalId, ids);
|
||
AddComponentIdsFromText(unit.AnchorId, ids);
|
||
AddComponentIdsFromText(unit.RetrievalSentence, ids);
|
||
AddComponentIdsFromText(unit.DedupKey, ids);
|
||
AddComponentIdsFromText(RawJson(unit.FactBundle), ids);
|
||
return ids;
|
||
}
|
||
|
||
static bool ImageBelongsToAnyComponent(MechanicalEvidenceImage image, IReadOnlySet<string> componentIds)
|
||
{
|
||
if (componentIds.Count == 0)
|
||
return false;
|
||
|
||
var text = $"{image.Name} {image.FilePath}".Replace('\\', '/').ToLowerInvariant();
|
||
return componentIds.Any(id => text.Contains(id.ToLowerInvariant(), StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
|
||
static void AddComponentIdsFromText(string? text, HashSet<string> ids)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(text))
|
||
return;
|
||
|
||
foreach (Match match in ComponentTokenRegex().Matches(text))
|
||
ids.Add(Regex.Replace(match.Value.ToLowerInvariant(), @"[-\s]+", "_"));
|
||
}
|
||
|
||
static List<MechanicalEvidenceImage> SelectIssueEvidenceImages(MechanicalRetrievedUnit unit, IReadOnlyList<MechanicalEvidenceImage> images)
|
||
{
|
||
if (images.Count == 0)
|
||
return [];
|
||
|
||
var text = BuildUnitSearchText(unit);
|
||
var scored = images
|
||
.Select(image => new
|
||
{
|
||
Image = image,
|
||
Score = ScoreImageForUnit(text, image),
|
||
Priority = EvidenceImagePriority(image.FilePath)
|
||
})
|
||
.OrderByDescending(item => item.Score)
|
||
.ThenBy(item => item.Priority)
|
||
.ThenBy(item => item.Image.Name, StringComparer.OrdinalIgnoreCase)
|
||
.Take(1)
|
||
.Where(item => item.Score > 0)
|
||
.Select(item => item.Image)
|
||
.ToList();
|
||
|
||
if (scored.Count > 0)
|
||
return scored;
|
||
|
||
var fallback = images
|
||
.OrderBy(image => IsGlobalModelImage(image) ? 1 : 0)
|
||
.ThenBy(image => EvidenceImagePriority(image.FilePath))
|
||
.ThenBy(image => image.Name, StringComparer.OrdinalIgnoreCase)
|
||
.FirstOrDefault();
|
||
|
||
return fallback == null ? [] : [fallback];
|
||
}
|
||
|
||
static bool IsGlobalModelImage(MechanicalEvidenceImage image)
|
||
{
|
||
var text = $"{image.Name} {image.FilePath}".ToLowerInvariant();
|
||
return !text.Contains("physical_interfaces") &&
|
||
!text.Contains("component_contexts") &&
|
||
!text.Contains("component_highlights") &&
|
||
!text.Contains("part_images");
|
||
}
|
||
|
||
static int ScoreImageForUnit(string issueText, MechanicalEvidenceImage image)
|
||
{
|
||
var imageText = $"{image.Name} {image.FilePath}".ToLowerInvariant();
|
||
var score = ScoreImageForIssue(issueText, image);
|
||
|
||
if (ImageEvidenceKeys(image).Any(key => issueText.Contains(key, StringComparison.OrdinalIgnoreCase)))
|
||
score += 80;
|
||
|
||
foreach (Match match in ComponentTokenRegex().Matches(issueText))
|
||
{
|
||
var token = Regex.Replace(match.Value.ToLowerInvariant(), @"\s+", "_");
|
||
var compact = token.Replace("-", "_");
|
||
if (imageText.Contains(token, StringComparison.OrdinalIgnoreCase) ||
|
||
imageText.Contains(compact, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
score += 30;
|
||
}
|
||
}
|
||
|
||
if ((issueText.Contains("internal") || issueText.Contains("内部")) &&
|
||
imageText.Contains("component_contexts", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
score += 12;
|
||
}
|
||
|
||
if ((issueText.Contains("external") || issueText.Contains("外部")) &&
|
||
imageText.Contains("component_highlights", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
score += 10;
|
||
}
|
||
|
||
if (imageText.Contains("component_highlights", StringComparison.OrdinalIgnoreCase))
|
||
score += 4;
|
||
if (imageText.Contains("component_contexts", StringComparison.OrdinalIgnoreCase))
|
||
score += 3;
|
||
if (imageText.Contains("part_images", StringComparison.OrdinalIgnoreCase))
|
||
score += 3;
|
||
|
||
return score;
|
||
}
|
||
|
||
static IEnumerable<string> ImageEvidenceKeys(MechanicalEvidenceImage image)
|
||
{
|
||
var values = new[]
|
||
{
|
||
image.Name,
|
||
image.Name.Replace("组件上下文截图:", "", StringComparison.OrdinalIgnoreCase),
|
||
image.Name.Replace("装配体高亮截图:", "", StringComparison.OrdinalIgnoreCase),
|
||
image.Name.Replace("零件图截图:", "", StringComparison.OrdinalIgnoreCase),
|
||
image.Name.Replace("等轴测截图:", "", StringComparison.OrdinalIgnoreCase),
|
||
image.Name.Replace("模型截图:", "", StringComparison.OrdinalIgnoreCase),
|
||
image.Name.Replace("功能组拆分后零件全脸高亮图:", "", StringComparison.OrdinalIgnoreCase),
|
||
image.Name.Replace("功能组接触配合证据图:", "", StringComparison.OrdinalIgnoreCase),
|
||
Path.GetFileNameWithoutExtension(image.FilePath),
|
||
Path.GetFileName(Path.GetDirectoryName(image.FilePath) ?? ""),
|
||
BuildRelativeImageKey(image.FilePath)
|
||
};
|
||
|
||
return values
|
||
.Select(value => value.Replace('\\', '/').Trim().ToLowerInvariant())
|
||
.Where(value => value.Length >= 3)
|
||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||
}
|
||
|
||
static string BuildRelativeImageKey(string path)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(path))
|
||
return "";
|
||
|
||
var normalized = path.Replace('\\', '/');
|
||
foreach (var marker in new[]
|
||
{
|
||
"functional_group_evidence/", "all_face_highlights/", "feature_highlights/",
|
||
"physical_interfaces/", "component_contexts/", "component_highlights/", "part_images/", "model_images/"
|
||
})
|
||
{
|
||
var index = normalized.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||
if (index >= 0)
|
||
return Path.ChangeExtension(normalized[index..], null) ?? normalized[index..];
|
||
}
|
||
|
||
return "";
|
||
}
|
||
|
||
[GeneratedRegex(@"\bcomponent[_\-\s]*\d+\b|\bpart[_\-\s]*\d+\b", RegexOptions.IgnoreCase)]
|
||
private static partial Regex ComponentTokenRegex();
|
||
|
||
static int ScoreImageForIssue(string issueText, MechanicalEvidenceImage image)
|
||
{
|
||
var name = image.Name.ToLowerInvariant();
|
||
var score = 0;
|
||
if (name.Contains("section"))
|
||
score += 6;
|
||
if (issueText.Contains("hole") && name.Contains("hole"))
|
||
score += 5;
|
||
if ((issueText.Contains("axis") || issueText.Contains("cylinder") || issueText.Contains("cylindrical")) && name.Contains("axis"))
|
||
score += 4;
|
||
if ((issueText.Contains("machined") || issueText.Contains("unmachined") || issueText.Contains("surface") || issueText.Contains("加工")) && name.Contains("section"))
|
||
score += 3;
|
||
if ((issueText.Contains("boss") || issueText.Contains("register") || issueText.Contains("flange") || issueText.Contains("凸台")) && name.Contains("section"))
|
||
score += 3;
|
||
if (name.Contains("isometric") || name.Contains("等轴"))
|
||
score += 1;
|
||
return score;
|
||
}
|
||
|
||
static string SanitizeFileName(string value)
|
||
{
|
||
var invalid = Path.GetInvalidFileNameChars().ToHashSet();
|
||
var cleaned = new string(value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray());
|
||
return string.IsNullOrWhiteSpace(cleaned) ? "model" : cleaned;
|
||
}
|
||
|
||
static void UpdateJob(MechanicalDiagnosisJobStatus? job, string? status = null, string? phase = null, string? message = null)
|
||
{
|
||
if (job == null)
|
||
return;
|
||
|
||
if (!string.IsNullOrWhiteSpace(status))
|
||
job.Status = status;
|
||
if (!string.IsNullOrWhiteSpace(phase))
|
||
job.Phase = phase;
|
||
if (message != null)
|
||
job.Message = message;
|
||
job.UpdatedAt = DateTimeOffset.Now;
|
||
}
|
||
}
|
||
|
||
sealed class MechanicalEvidenceRequest
|
||
{
|
||
public string ModelPath { get; set; } = "";
|
||
public string? OutputDir { get; set; }
|
||
public bool ExportImages { get; set; } = true;
|
||
public bool ExportSectionImages { get; set; }
|
||
public bool SkipSectionBrep { get; set; } = true;
|
||
}
|
||
|
||
sealed class MechanicalBridgeRequest
|
||
{
|
||
public string ModelPath { get; set; } = "";
|
||
public string? OutputDir { get; set; }
|
||
public bool NoAi { get; set; }
|
||
public bool Deep { get; set; }
|
||
public int MaxIterations { get; set; } = 1;
|
||
public string Model { get; set; } = "";
|
||
public string? ProductDescription { get; set; }
|
||
public string? PartFunctionProfile { get; set; }
|
||
public List<string> PurchasedComponentHints { get; set; } = [];
|
||
}
|
||
|
||
sealed class MechanicalInterfaceFinalizeRequest
|
||
{
|
||
public string OutputDir { get; set; } = "";
|
||
public string? ModelPath { get; set; }
|
||
public string Stage { get; set; } = "all";
|
||
public string Model { get; set; } = "";
|
||
public int MaxImages { get; set; } = 6;
|
||
}
|
||
|
||
sealed class MechanicalDiagnosticUsage
|
||
{
|
||
public string FlowBoundaryDocPath { get; set; } = "";
|
||
public string FlowBoundaryMarkdown { get; set; } = "";
|
||
public string ContractDocPath { get; set; } = "";
|
||
public string ContractMarkdown { get; set; } = "";
|
||
public string UsageDocPath { get; set; } = "";
|
||
public string UsageMarkdown { get; set; } = "";
|
||
public string KnowledgeIndexRoot { get; set; } = "";
|
||
public List<string> KnowledgeFiles { get; set; } = [];
|
||
public List<string> SupportedStages { get; set; } = [];
|
||
public bool AiConfigured { get; set; }
|
||
public string AiModel { get; set; } = "";
|
||
public string AiTextModel { get; set; } = "";
|
||
public string AiBaseUrl { get; set; } = "";
|
||
public string AiApiMode { get; set; } = "";
|
||
public string BackendRole { get; set; } = "";
|
||
}
|
||
|
||
sealed class MechanicalDiagnosticRunResult
|
||
{
|
||
public bool Ok { get; set; }
|
||
public string Stage { get; set; } = "";
|
||
public string ModelPath { get; set; } = "";
|
||
public string OutputDir { get; set; } = "";
|
||
public MechanicalCommandResult Command { get; set; } = new();
|
||
public List<MechanicalArtifact> Artifacts { get; set; } = [];
|
||
public string NextHint { get; set; } = "";
|
||
}
|
||
|
||
sealed class MechanicalAiDiagnosisRequest
|
||
{
|
||
public string ModelPath { get; set; } = "";
|
||
public string? OutputDir { get; set; }
|
||
public bool Deep { get; set; } = true;
|
||
public int MaxIterations { get; set; } = 1;
|
||
public int MaxRulesPerUnit { get; set; } = 4;
|
||
public string Model { get; set; } = "";
|
||
public string? ProductDescription { get; set; }
|
||
public string? PartFunctionProfile { get; set; }
|
||
public List<string> PurchasedComponentHints { get; set; } = [];
|
||
}
|
||
|
||
sealed class MechanicalExistingDiagnosisRequest
|
||
{
|
||
public string? OutputDir { get; set; }
|
||
public string ModelPath { get; set; } = "";
|
||
public int MaxRulesPerUnit { get; set; } = 4;
|
||
public string Model { get; set; } = "";
|
||
public List<string> ComponentIds { get; set; } = [];
|
||
}
|
||
|
||
sealed class MechanicalRetrievalOnlyRequest
|
||
{
|
||
public string? OutputDir { get; set; }
|
||
public int MaxRulesPerUnit { get; set; } = 4;
|
||
public List<string> ComponentIds { get; set; } = [];
|
||
}
|
||
|
||
sealed class MechanicalRetrievalOnlyResult
|
||
{
|
||
public bool Ok { get; set; }
|
||
public string OutputDir { get; set; } = "";
|
||
public string SemanticGraphPath { get; set; } = "";
|
||
public string RetrievalPath { get; set; } = "";
|
||
public string SelectedForJudgementPath { get; set; } = "";
|
||
public string RuleJudgementInputsPath { get; set; } = "";
|
||
public string ReportPath { get; set; } = "";
|
||
public int UnitCount { get; set; }
|
||
public int RetrievedCandidateCount { get; set; }
|
||
public List<string> ContractIssues { get; set; } = [];
|
||
public List<MechanicalRetrievedIssue> RetrievedIssues { get; set; } = [];
|
||
}
|
||
|
||
sealed class MechanicalAiDiagnosisResult
|
||
{
|
||
public bool Ok { get; set; }
|
||
public string ModelPath { get; set; } = "";
|
||
public string OutputDir { get; set; } = "";
|
||
public MechanicalDiagnosticRunResult Bridge { get; set; } = new();
|
||
public string SemanticGraphPath { get; set; } = "";
|
||
public string RetrievalPath { get; set; } = "";
|
||
public string FinalReportPath { get; set; } = "";
|
||
public string FinalResponsePath { get; set; } = "";
|
||
public string FinalStatus { get; set; } = "";
|
||
public string FinalMessage { get; set; } = "";
|
||
public string FinalReportMarkdown { get; set; } = "";
|
||
public int RetrievedCandidateCount { get; set; }
|
||
public List<MechanicalRetrievedIssue> RetrievedIssues { get; set; } = [];
|
||
public List<MechanicalFinalIssue> FinalIssues { get; set; } = [];
|
||
public List<MechanicalArtifact> Artifacts { get; set; } = [];
|
||
}
|
||
|
||
sealed class MechanicalDiagnosisJobStatus
|
||
{
|
||
public string JobId { get; set; } = "";
|
||
public string Status { get; set; } = "running";
|
||
public string Phase { get; set; } = "";
|
||
public string Message { get; set; } = "";
|
||
public string ModelPath { get; set; } = "";
|
||
public string OutputDir { get; set; } = "";
|
||
public int TotalUnits { get; set; }
|
||
public int ProcessedUnits { get; set; }
|
||
public int RemainingUnits => Math.Max(0, TotalUnits - ProcessedUnits);
|
||
public int RetrievedCandidateCount { get; set; }
|
||
public int TotalSubgraphComponents { get; set; }
|
||
public int ProcessedSubgraphComponents { get; set; }
|
||
public MechanicalSubgraphComponentProgress? CurrentSubgraphComponent { get; set; }
|
||
public MechanicalDiagnosisUnitProgress? CurrentUnit { get; set; }
|
||
public List<MechanicalRetrievedIssue> RetrievedIssues { get; set; } = [];
|
||
public MechanicalDiagnosticRunResult? Bridge { get; set; }
|
||
public MechanicalAiDiagnosisResult? Result { get; set; }
|
||
public string Error { get; set; } = "";
|
||
public DateTimeOffset StartedAt { get; set; }
|
||
public DateTimeOffset UpdatedAt { get; set; }
|
||
}
|
||
|
||
sealed class MechanicalSubgraphComponentProgress
|
||
{
|
||
public string ComponentId { get; set; } = "";
|
||
public string InstanceName { get; set; } = "";
|
||
public string DisplayName { get; set; } = "";
|
||
public string EvidencePath { get; set; } = "";
|
||
public int CurrentIndex { get; set; }
|
||
public int TotalComponents { get; set; }
|
||
public int ProcessedComponents { get; set; }
|
||
public int Iteration { get; set; }
|
||
public string Status { get; set; } = "";
|
||
public string Message { get; set; } = "";
|
||
public int FeatureCount { get; set; }
|
||
public int FaceCount { get; set; }
|
||
public int ContactCount { get; set; }
|
||
public int MateCount { get; set; }
|
||
public int ImageCount { get; set; }
|
||
public List<string> AdjacentComponentIds { get; set; } = [];
|
||
}
|
||
|
||
sealed class MechanicalDiagnosisUnitProgress
|
||
{
|
||
public string UnitKey { get; set; } = "";
|
||
public string ComponentId { get; set; } = "";
|
||
public string InstanceName { get; set; } = "";
|
||
public string LocalId { get; set; } = "";
|
||
public string DisplayName { get; set; } = "";
|
||
public string RetrievalSentence { get; set; } = "";
|
||
public string PrimaryView { get; set; } = "";
|
||
public int CandidateCount { get; set; }
|
||
|
||
public static MechanicalDiagnosisUnitProgress From(MechanicalRetrievedUnit unit)
|
||
{
|
||
var displayName = FirstFactString(
|
||
unit.FactBundle,
|
||
"component_display_name",
|
||
"display_name",
|
||
"component_name",
|
||
"component_id",
|
||
"instance_name",
|
||
"semantic_location");
|
||
|
||
return new MechanicalDiagnosisUnitProgress
|
||
{
|
||
UnitKey = unit.UnitKey,
|
||
ComponentId = unit.ComponentId,
|
||
InstanceName = unit.InstanceName,
|
||
LocalId = unit.LocalId,
|
||
DisplayName = FirstNonEmpty(unit.InstanceName, displayName, unit.UnitKey, unit.LocalId, unit.RetrievalSentence, "未命名局部单元"),
|
||
RetrievalSentence = unit.RetrievalSentence,
|
||
PrimaryView = unit.PrimaryView,
|
||
CandidateCount = unit.Candidates.Count
|
||
};
|
||
}
|
||
|
||
static string FirstNonEmpty(params string[] values)
|
||
{
|
||
return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? "";
|
||
}
|
||
|
||
static string FirstFactString(JsonElement element, params string[] names)
|
||
{
|
||
if (element.ValueKind == JsonValueKind.Undefined || element.ValueKind == JsonValueKind.Null)
|
||
return "";
|
||
|
||
var targets = names.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||
return FindString(element, targets);
|
||
}
|
||
|
||
static string FindString(JsonElement element, HashSet<string> names)
|
||
{
|
||
if (element.ValueKind == JsonValueKind.Object)
|
||
{
|
||
foreach (var property in element.EnumerateObject())
|
||
{
|
||
if (names.Contains(property.Name) && property.Value.ValueKind == JsonValueKind.String)
|
||
return property.Value.GetString() ?? "";
|
||
|
||
var nested = FindString(property.Value, names);
|
||
if (!string.IsNullOrWhiteSpace(nested))
|
||
return nested;
|
||
}
|
||
}
|
||
else if (element.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var item in element.EnumerateArray())
|
||
{
|
||
var nested = FindString(item, names);
|
||
if (!string.IsNullOrWhiteSpace(nested))
|
||
return nested;
|
||
}
|
||
}
|
||
|
||
return "";
|
||
}
|
||
}
|
||
|
||
sealed class MechanicalEvidenceImage
|
||
{
|
||
public string Name { get; set; } = "";
|
||
public string Url { get; set; } = "";
|
||
public string ImageKind { get; set; } = "";
|
||
public string EvidenceFamily { get; set; } = "";
|
||
public string EvidencePurpose { get; set; } = "";
|
||
public string PositionVisibility { get; set; } = "";
|
||
public string SourceUnitKey { get; set; } = "";
|
||
public string SourceLocalId { get; set; } = "";
|
||
public string SourceAnchorId { get; set; } = "";
|
||
[JsonIgnore]
|
||
public string FilePath { get; set; } = "";
|
||
[JsonIgnore]
|
||
public string ContentType { get; set; } = "";
|
||
[JsonIgnore]
|
||
public List<string> BindingRefs { get; set; } = [];
|
||
}
|
||
|
||
sealed class MechanicalEvidenceImageMetadata
|
||
{
|
||
public string ImageKind { get; set; } = "";
|
||
public string EvidenceFamily { get; set; } = "";
|
||
public string EvidencePurpose { get; set; } = "";
|
||
public string PositionVisibility { get; set; } = "";
|
||
public List<string> BindingRefs { get; set; } = [];
|
||
}
|
||
|
||
sealed class MechanicalEvidenceImageFile
|
||
{
|
||
public string FilePath { get; set; } = "";
|
||
public string ContentType { get; set; } = "";
|
||
}
|
||
|
||
sealed class MechanicalRetrievedIssue
|
||
{
|
||
public string UnitKey { get; set; } = "";
|
||
public string ComponentId { get; set; } = "";
|
||
public string InstanceName { get; set; } = "";
|
||
public string LocalId { get; set; } = "";
|
||
public string RetrievalSentence { get; set; } = "";
|
||
public string AnchorType { get; set; } = "";
|
||
public string AnchorId { get; set; } = "";
|
||
public string PatternType { get; set; } = "";
|
||
public string RuleId { get; set; } = "";
|
||
public string RuleTitle { get; set; } = "";
|
||
public string PrimaryView { get; set; } = "";
|
||
public int Score { get; set; }
|
||
public List<string> ImageEvidenceNames { get; set; } = [];
|
||
public List<MechanicalEvidenceImage> EvidenceImages { get; set; } = [];
|
||
}
|
||
|
||
sealed class MechanicalFinalIssue
|
||
{
|
||
public string Judgement { get; set; } = "";
|
||
public string RuleId { get; set; } = "";
|
||
public string RuleTitle { get; set; } = "";
|
||
public string Location { get; set; } = "";
|
||
public string Reason { get; set; } = "";
|
||
public string Suggestion { get; set; } = "";
|
||
public string ReportBlockMarkdown { get; set; } = "";
|
||
public List<string> RelatedUnitKeys { get; set; } = [];
|
||
public List<string> RelatedAnchorIds { get; set; } = [];
|
||
public List<MechanicalEvidenceImage> EvidenceImages { get; set; } = [];
|
||
}
|
||
|
||
static class MechanicalDiagnosticFlowTerms
|
||
{
|
||
public const string StageSinglePartCheck = "single_part_check";
|
||
public const string StageFunctionalGroupCheck = "functional_group_check";
|
||
public const string StageGroupInternalPartCheck = "group_internal_part_check";
|
||
public const string KnowledgeScopePart = "part_knowledge";
|
||
public const string KnowledgeScopeAssembly = "assembly_knowledge";
|
||
}
|
||
|
||
sealed class MechanicalRulePackage
|
||
{
|
||
public string Scope { get; set; } = "";
|
||
public string IndexPath { get; set; } = "";
|
||
public string StorePath { get; set; } = "";
|
||
public List<JsonElement> Items { get; set; } = [];
|
||
public List<JsonElement> Rules { get; set; } = [];
|
||
}
|
||
|
||
sealed class MechanicalKnowledgeRetrievalResult
|
||
{
|
||
public string Schema { get; set; } = "";
|
||
public string KnowledgeScope { get; set; } = "";
|
||
public string IndexPath { get; set; } = "";
|
||
public string StorePath { get; set; } = "";
|
||
public List<MechanicalRetrievedUnit> Units { get; set; } = [];
|
||
}
|
||
|
||
sealed class MechanicalRetrievedUnit
|
||
{
|
||
public string UnitKey { get; set; } = "";
|
||
public string ComponentId { get; set; } = "";
|
||
public string InstanceName { get; set; } = "";
|
||
public string LocalId { get; set; } = "";
|
||
public string DedupKey { get; set; } = "";
|
||
public string DiagnosticStage { get; set; } = "";
|
||
public string KnowledgeScope { get; set; } = "";
|
||
public string RetrievalSentence { get; set; } = "";
|
||
public string AnchorType { get; set; } = "";
|
||
public string AnchorId { get; set; } = "";
|
||
public string PrimaryView { get; set; } = "";
|
||
public string PatternType { get; set; } = "";
|
||
public List<string> InspectionCardRefs { get; set; } = [];
|
||
public List<string> ImageEvidenceNames { get; set; } = [];
|
||
public JsonElement FactBundle { get; set; }
|
||
public List<MechanicalRuleCandidate> Candidates { get; set; } = [];
|
||
public List<MechanicalRuleCandidate> RerankedCandidates { get; set; } = [];
|
||
public MechanicalRetrievalSelectionPolicy RetrievalSelectionPolicy { get; set; } = new();
|
||
}
|
||
|
||
sealed class MechanicalRetrievalSelectionPolicy
|
||
{
|
||
public int WideCandidateLimit { get; set; }
|
||
public int JudgementScoreThreshold { get; set; }
|
||
public int ConditionalScoreThreshold { get; set; }
|
||
public int ConditionalEvidenceThreshold { get; set; }
|
||
public int InspectionCardLookupThreshold { get; set; }
|
||
public int MaxJudgementCandidates { get; set; }
|
||
}
|
||
|
||
sealed class MechanicalRuleSearchDocument
|
||
{
|
||
public JsonElement Item { get; set; }
|
||
public JsonElement Detail { get; set; }
|
||
public string RuleId { get; set; } = "";
|
||
public string Code { get; set; } = "";
|
||
public string Title { get; set; } = "";
|
||
public string PrimaryView { get; set; } = "";
|
||
public List<string> SecondaryViews { get; set; } = [];
|
||
public List<string> RequiredModelFacts { get; set; } = [];
|
||
public string RuleRetrievalSentence { get; set; } = "";
|
||
public string RetrievalText { get; set; } = "";
|
||
public List<string> Terms { get; set; } = [];
|
||
public Dictionary<string, int> TermCounts { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||
}
|
||
|
||
sealed class MechanicalBm25Corpus
|
||
{
|
||
public int DocumentCount { get; set; }
|
||
public double AverageDocumentLength { get; set; }
|
||
public Dictionary<string, double> InverseDocumentFrequency { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||
}
|
||
|
||
sealed class MechanicalRuleCandidate
|
||
{
|
||
public string RuleId { get; set; } = "";
|
||
public string Code { get; set; } = "";
|
||
public string Title { get; set; } = "";
|
||
public string PrimaryView { get; set; } = "";
|
||
public string RuleRetrievalSentence { get; set; } = "";
|
||
public int Score { get; set; }
|
||
public Dictionary<string, int> ScoreBreakdown { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||
public bool ViewMatched { get; set; }
|
||
public bool SelectedForJudgement { get; set; }
|
||
public string SelectionReason { get; set; } = "";
|
||
public JsonElement IndexEntry { get; set; }
|
||
public JsonElement RuleDetail { get; set; }
|
||
}
|
||
|
||
sealed class MechanicalLocalUnit
|
||
{
|
||
public string UnitKey { get; set; } = "";
|
||
public string ComponentId { get; set; } = "";
|
||
public string InstanceName { get; set; } = "";
|
||
public string LocalId { get; set; } = "";
|
||
public string DedupKey { get; set; } = "";
|
||
public string DiagnosticStage { get; set; } = "";
|
||
public string KnowledgeScope { get; set; } = "";
|
||
public string AnchorType { get; set; } = "";
|
||
public string AnchorId { get; set; } = "";
|
||
public string PrimaryView { get; set; } = "";
|
||
public List<string> SecondaryViews { get; set; } = [];
|
||
public string RetrievalSentence { get; set; } = "";
|
||
public JsonElement RetrievalChain { get; set; }
|
||
public List<string> InspectionCardRefs { get; set; } = [];
|
||
public List<string> RetrievalSemanticRoles { get; set; } = [];
|
||
public List<string> InferredFunctions { get; set; } = [];
|
||
public List<string> RetrievalFactTags { get; set; } = [];
|
||
public List<string> RetrievalRelations { get; set; } = [];
|
||
public List<string> ImageEvidenceNames { get; set; } = [];
|
||
public string InferredAttributesText { get; set; } = "";
|
||
public string NormalizedMeasurementsText { get; set; } = "";
|
||
public string PatternType { get; set; } = "";
|
||
public JsonElement FactBundle { get; set; }
|
||
}
|
||
|
||
sealed class MechanicalFinalJudgeResult
|
||
{
|
||
public string Status { get; set; } = "";
|
||
public string Message { get; set; } = "";
|
||
public string FinalReportPath { get; set; } = "";
|
||
public string RawResponsePath { get; set; } = "";
|
||
}
|
||
|
||
sealed class MechanicalOpenAiSettings
|
||
{
|
||
public string ApiKey { get; init; } = "";
|
||
public string Model { get; init; } = "qwen-vl-max";
|
||
public string TextModel { get; init; } = "qwen-plus";
|
||
public string BaseUrl { get; init; } = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||
public string ApiMode { get; init; } = "chat_completions";
|
||
public string Proxy { get; init; } = "";
|
||
public int MaxImages { get; init; } = 6;
|
||
public bool HasApiKey => !string.IsNullOrWhiteSpace(ApiKey);
|
||
|
||
public static MechanicalOpenAiSettings From(IConfiguration configuration)
|
||
{
|
||
var apiKey = FirstNonEmpty(
|
||
configuration["AI:ApiKey"],
|
||
configuration["OpenAI:ApiKey"],
|
||
configuration["OPENAI_API_KEY"],
|
||
Environment.GetEnvironmentVariable("OPENAI_API_KEY"));
|
||
var model = FirstNonEmpty(
|
||
configuration["AI:Model"],
|
||
configuration["OpenAI:Model"],
|
||
configuration["OPENAI_MODEL"],
|
||
Environment.GetEnvironmentVariable("OPENAI_MODEL"),
|
||
"qwen-vl-max");
|
||
var textModel = FirstNonEmpty(
|
||
configuration["AI:TextModel"],
|
||
configuration["OpenAI:TextModel"],
|
||
configuration["OPENAI_TEXT_MODEL"],
|
||
Environment.GetEnvironmentVariable("OPENAI_TEXT_MODEL"),
|
||
"qwen-plus");
|
||
var baseUrl = NormalizeBaseUrl(FirstNonEmpty(
|
||
configuration["AI:BaseUrl"],
|
||
configuration["OpenAI:BaseUrl"],
|
||
configuration["OPENAI_BASE_URL"],
|
||
Environment.GetEnvironmentVariable("OPENAI_BASE_URL"),
|
||
"https://dashscope.aliyuncs.com/compatible-mode/v1"));
|
||
var apiMode = NormalizeApiMode(FirstNonEmpty(
|
||
configuration["AI:ApiMode"],
|
||
configuration["OpenAI:ApiMode"],
|
||
configuration["OPENAI_API_MODE"],
|
||
Environment.GetEnvironmentVariable("OPENAI_API_MODE"),
|
||
"chat_completions"));
|
||
var proxy = FirstNonEmpty(
|
||
configuration["AI:Proxy"],
|
||
configuration["OpenAI:Proxy"],
|
||
configuration["HTTPS_PROXY"],
|
||
configuration["HTTP_PROXY"],
|
||
configuration["ALL_PROXY"],
|
||
Environment.GetEnvironmentVariable("HTTPS_PROXY"),
|
||
Environment.GetEnvironmentVariable("HTTP_PROXY"),
|
||
Environment.GetEnvironmentVariable("ALL_PROXY"));
|
||
var maxImagesText = FirstNonEmpty(
|
||
configuration["AI:MaxImages"],
|
||
configuration["OpenAI:MaxImages"],
|
||
configuration["OPENAI_MAX_IMAGES"],
|
||
Environment.GetEnvironmentVariable("OPENAI_MAX_IMAGES"),
|
||
"12");
|
||
|
||
return new MechanicalOpenAiSettings
|
||
{
|
||
ApiKey = apiKey,
|
||
Model = model,
|
||
TextModel = textModel,
|
||
BaseUrl = baseUrl,
|
||
ApiMode = apiMode,
|
||
Proxy = proxy,
|
||
MaxImages = int.TryParse(maxImagesText, out var maxImages) ? Math.Clamp(maxImages, 0, 30) : 6
|
||
};
|
||
}
|
||
|
||
public string ResolveModel(string? requestedModel)
|
||
{
|
||
return FirstNonEmpty(requestedModel, Model, "qwen-vl-max");
|
||
}
|
||
|
||
public string ResolveTextModel(string? requestedModel)
|
||
{
|
||
return FirstNonEmpty(requestedModel, TextModel, Model, "qwen-plus");
|
||
}
|
||
|
||
public string RequireApiKey()
|
||
{
|
||
if (HasApiKey)
|
||
return ApiKey;
|
||
|
||
throw new InvalidOperationException("AI API key is required for mechanical diagnosis. Set AI:ApiKey in backend-csharp/Agent4.Api/appsettings.Local.json, .NET user-secrets, or OPENAI_API_KEY.");
|
||
}
|
||
|
||
static string FirstNonEmpty(params string?[] values)
|
||
{
|
||
foreach (var value in values)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(value))
|
||
return value.Trim();
|
||
}
|
||
|
||
return "";
|
||
}
|
||
|
||
static string NormalizeBaseUrl(string value)
|
||
{
|
||
var trimmed = string.IsNullOrWhiteSpace(value) ? "https://dashscope.aliyuncs.com/compatible-mode/v1" : value.Trim();
|
||
return trimmed.TrimEnd('/');
|
||
}
|
||
|
||
static string NormalizeApiMode(string value)
|
||
{
|
||
var trimmed = FirstNonEmpty(value, "chat_completions").ToLowerInvariant();
|
||
return trimmed is "responses" ? "responses" : "chat_completions";
|
||
}
|
||
}
|
||
|
||
sealed class MechanicalCommandResult
|
||
{
|
||
public string ProjectPath { get; set; } = "";
|
||
public List<string> Arguments { get; set; } = [];
|
||
public int ExitCode { get; set; }
|
||
public string StandardOutput { get; set; } = "";
|
||
public string StandardError { get; set; } = "";
|
||
public DateTimeOffset StartedAt { get; set; }
|
||
public DateTimeOffset CompletedAt { get; set; }
|
||
}
|
||
|
||
sealed class MechanicalArtifact
|
||
{
|
||
public string Name { get; set; } = "";
|
||
public string FilePath { get; set; } = "";
|
||
public bool Exists { get; set; }
|
||
public long Length { get; set; }
|
||
public DateTimeOffset? LastModified { get; set; }
|
||
|
||
public static MechanicalArtifact From(string outputDir, string relativePath)
|
||
{
|
||
var fullPath = Path.Combine(outputDir, relativePath);
|
||
var file = new FileInfo(fullPath);
|
||
return new MechanicalArtifact
|
||
{
|
||
Name = relativePath.Replace(Path.DirectorySeparatorChar, '/'),
|
||
FilePath = fullPath,
|
||
Exists = file.Exists,
|
||
Length = file.Exists ? file.Length : 0,
|
||
LastModified = file.Exists ? file.LastWriteTime : null
|
||
};
|
||
}
|
||
}
|
||
|
||
static partial class MechanicalFinalJudge
|
||
{
|
||
sealed class MechanicalJudgementBatch
|
||
{
|
||
public string Key { get; set; } = "";
|
||
public List<MechanicalRetrievedUnit> Units { get; set; } = [];
|
||
}
|
||
|
||
sealed class MechanicalHoleDiameterEvidence
|
||
{
|
||
public MechanicalRetrievedUnit Unit { get; init; } = new();
|
||
public double DiameterMm { get; init; }
|
||
public List<string> Roles { get; init; } = [];
|
||
}
|
||
|
||
public static async Task<MechanicalFinalJudgeResult> CallAsync(
|
||
string model,
|
||
string apiKey,
|
||
string baseUrl,
|
||
string apiMode,
|
||
string proxy,
|
||
string semanticGraphJson,
|
||
MechanicalKnowledgeRetrievalResult retrieval,
|
||
string outputDir,
|
||
string filePrefix = "final_ai_diagnostic",
|
||
IReadOnlyList<MechanicalEvidenceImage>? evidenceImages = null,
|
||
int maxImages = 0)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(apiKey))
|
||
throw new InvalidOperationException("AI API key is required for the complete mechanical diagnostic flow. Configure AI:ApiKey in appsettings.Local.json, user-secrets, or OPENAI_API_KEY.");
|
||
|
||
var attachedImages = SelectAttachedImages(evidenceImages, maxImages);
|
||
var prompt = BuildPrompt(semanticGraphJson, retrieval, attachedImages);
|
||
var rawPath = Path.Combine(outputDir, $"{filePrefix}_response.json");
|
||
var reportPath = Path.Combine(outputDir, $"{filePrefix}_report.md");
|
||
|
||
using var handler = new HttpClientHandler();
|
||
if (!string.IsNullOrWhiteSpace(proxy))
|
||
{
|
||
handler.UseProxy = true;
|
||
handler.Proxy = new WebProxy(proxy);
|
||
}
|
||
|
||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(12) };
|
||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||
object request;
|
||
string endpoint;
|
||
if (string.Equals(apiMode, "responses", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
endpoint = $"{baseUrl.TrimEnd('/')}/responses";
|
||
request = new
|
||
{
|
||
model,
|
||
input = new object[]
|
||
{
|
||
new
|
||
{
|
||
role = "user",
|
||
content = BuildResponsesContent(prompt, attachedImages)
|
||
}
|
||
}
|
||
};
|
||
}
|
||
else
|
||
{
|
||
endpoint = $"{baseUrl.TrimEnd('/')}/chat/completions";
|
||
object messageContent = attachedImages.Count == 0 ? prompt : BuildChatContent(prompt, attachedImages);
|
||
request = new
|
||
{
|
||
model,
|
||
messages = new object[]
|
||
{
|
||
new
|
||
{
|
||
role = "user",
|
||
content = messageContent
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
using var content = new StringContent(JsonSerializer.Serialize(request, MechanicalJsonOptions.Default), Encoding.UTF8, "application/json");
|
||
HttpResponseMessage response;
|
||
string raw;
|
||
try
|
||
{
|
||
response = await http.PostAsync(endpoint, content);
|
||
raw = await response.Content.ReadAsStringAsync();
|
||
}
|
||
catch (TaskCanceledException)
|
||
{
|
||
var timeoutText = "AI final judgement timed out after 12 minutes. The request may be too large or the model is responding slowly.";
|
||
File.WriteAllText(rawPath, timeoutText, new UTF8Encoding(false));
|
||
return new MechanicalFinalJudgeResult
|
||
{
|
||
Status = "error",
|
||
Message = timeoutText,
|
||
RawResponsePath = rawPath
|
||
};
|
||
}
|
||
File.WriteAllText(rawPath, raw, new UTF8Encoding(false));
|
||
if (!response.IsSuccessStatusCode)
|
||
{
|
||
return new MechanicalFinalJudgeResult
|
||
{
|
||
Status = "error",
|
||
Message = $"OpenAI API returned {(int)response.StatusCode}: {response.ReasonPhrase}",
|
||
RawResponsePath = rawPath
|
||
};
|
||
}
|
||
|
||
var text = ExtractResponseText(raw);
|
||
File.WriteAllText(reportPath, text, new UTF8Encoding(false));
|
||
return new MechanicalFinalJudgeResult
|
||
{
|
||
Status = "ok",
|
||
Message = "AI final diagnostic report generated.",
|
||
FinalReportPath = reportPath,
|
||
RawResponsePath = rawPath
|
||
};
|
||
}
|
||
|
||
static List<MechanicalEvidenceImage> SelectAttachedImages(IReadOnlyList<MechanicalEvidenceImage>? evidenceImages, int maxImages)
|
||
{
|
||
if (evidenceImages == null || evidenceImages.Count == 0 || maxImages <= 0)
|
||
return [];
|
||
|
||
return evidenceImages
|
||
.Where(image => !string.IsNullOrWhiteSpace(image.FilePath) && File.Exists(image.FilePath))
|
||
.Select(image => new { Image = image, File = new FileInfo(image.FilePath) })
|
||
.Where(item => item.File.Exists && item.File.Length > 0 && item.File.Length <= 5 * 1024 * 1024)
|
||
.DistinctBy(item => item.Image.FilePath, StringComparer.OrdinalIgnoreCase)
|
||
.Take(maxImages)
|
||
.Select(item => item.Image)
|
||
.ToList();
|
||
}
|
||
|
||
static List<object> BuildResponsesContent(string prompt, IReadOnlyList<MechanicalEvidenceImage> images)
|
||
{
|
||
var content = new List<object>
|
||
{
|
||
new
|
||
{
|
||
type = "input_text",
|
||
text = prompt
|
||
}
|
||
};
|
||
|
||
foreach (var image in images)
|
||
{
|
||
var dataUrl = BuildImageDataUrl(image);
|
||
if (string.IsNullOrWhiteSpace(dataUrl))
|
||
continue;
|
||
content.Add(new
|
||
{
|
||
type = "input_image",
|
||
image_url = dataUrl
|
||
});
|
||
}
|
||
|
||
return content;
|
||
}
|
||
|
||
static List<object> BuildChatContent(string prompt, IReadOnlyList<MechanicalEvidenceImage> images)
|
||
{
|
||
var content = new List<object>
|
||
{
|
||
new
|
||
{
|
||
type = "text",
|
||
text = prompt
|
||
}
|
||
};
|
||
|
||
foreach (var image in images)
|
||
{
|
||
var dataUrl = BuildImageDataUrl(image);
|
||
if (string.IsNullOrWhiteSpace(dataUrl))
|
||
continue;
|
||
content.Add(new
|
||
{
|
||
type = "image_url",
|
||
image_url = new
|
||
{
|
||
url = dataUrl
|
||
}
|
||
});
|
||
}
|
||
|
||
return content;
|
||
}
|
||
|
||
static string BuildImageDataUrl(MechanicalEvidenceImage image)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(image.FilePath) || !File.Exists(image.FilePath))
|
||
return "";
|
||
|
||
var file = new FileInfo(image.FilePath);
|
||
if (!file.Exists || file.Length <= 0 || file.Length > 5 * 1024 * 1024)
|
||
return "";
|
||
|
||
var contentType = string.IsNullOrWhiteSpace(image.ContentType)
|
||
? ContentTypeForImage(image.FilePath)
|
||
: image.ContentType;
|
||
return $"data:{contentType};base64,{Convert.ToBase64String(File.ReadAllBytes(image.FilePath))}";
|
||
}
|
||
|
||
static string ContentTypeForImage(string path) =>
|
||
Path.GetExtension(path).ToLowerInvariant() switch
|
||
{
|
||
".jpg" or ".jpeg" => "image/jpeg",
|
||
".png" => "image/png",
|
||
".webp" => "image/webp",
|
||
_ => "application/octet-stream"
|
||
};
|
||
|
||
public static async Task<MechanicalFinalJudgeResult> CallPerUnitAsync(
|
||
string model,
|
||
string apiKey,
|
||
string baseUrl,
|
||
string apiMode,
|
||
string proxy,
|
||
string semanticGraphJson,
|
||
MechanicalKnowledgeRetrievalResult retrieval,
|
||
string outputDir,
|
||
IReadOnlyDictionary<string, List<MechanicalEvidenceImage>> evidenceImagesByUnit,
|
||
int maxImages,
|
||
Action<MechanicalRetrievedUnit, int, int>? onUnitStarted = null,
|
||
Action<MechanicalRetrievedUnit, int, int>? onUnitCompleted = null)
|
||
{
|
||
Directory.CreateDirectory(outputDir);
|
||
var combinedReportPath = Path.Combine(outputDir, "final_ai_diagnostic_report.md");
|
||
var combinedRawPath = Path.Combine(outputDir, "final_ai_diagnostic_response.json");
|
||
var reports = new List<(string Branch, string Text)>();
|
||
var rawSummaries = new List<object>();
|
||
var status = "ok";
|
||
var messages = new List<string>();
|
||
var units = retrieval.Units;
|
||
var batches = BuildJudgementBatches(units);
|
||
var total = units.Count;
|
||
|
||
if (total == 0)
|
||
{
|
||
var emptyReport = "# 机械设计查错结果\n\n未检索到可用于最终判定的局部语义单元。";
|
||
File.WriteAllText(combinedReportPath, emptyReport, new UTF8Encoding(false));
|
||
File.WriteAllText(combinedRawPath, JsonSerializer.Serialize(new { status = "ok", units = Array.Empty<object>() }, MechanicalJsonOptions.Default), new UTF8Encoding(false));
|
||
return new MechanicalFinalJudgeResult
|
||
{
|
||
Status = "ok",
|
||
Message = "No local semantic units were available for final judgement.",
|
||
FinalReportPath = combinedReportPath,
|
||
RawResponsePath = combinedRawPath
|
||
};
|
||
}
|
||
|
||
var processedUnits = 0;
|
||
for (var index = 0; index < batches.Count; index++)
|
||
{
|
||
var batch = batches[index];
|
||
var unit = batch.Units[0];
|
||
onUnitStarted?.Invoke(unit, processedUnits, total);
|
||
|
||
if (batch.Units.All(item => item.Candidates.Count == 0))
|
||
{
|
||
rawSummaries.Add(new
|
||
{
|
||
batch_key = batch.Key,
|
||
unit_keys = batch.Units.Select(item => item.UnitKey).ToList(),
|
||
component_id = unit.ComponentId,
|
||
instance_name = unit.InstanceName,
|
||
diagnostic_stage = unit.DiagnosticStage,
|
||
knowledge_scope = unit.KnowledgeScope,
|
||
status = "skipped",
|
||
message = "No candidate rules."
|
||
});
|
||
processedUnits += batch.Units.Count;
|
||
onUnitCompleted?.Invoke(unit, processedUnits, total);
|
||
continue;
|
||
}
|
||
|
||
var oneUnitRetrieval = new MechanicalKnowledgeRetrievalResult
|
||
{
|
||
Schema = retrieval.Schema,
|
||
KnowledgeScope = retrieval.KnowledgeScope,
|
||
IndexPath = retrieval.IndexPath,
|
||
StorePath = retrieval.StorePath,
|
||
Units = batch.Units
|
||
};
|
||
var prefix = $"final_ai_diagnostic_{index + 1:000}_{SanitizeFileName(batch.Key)}";
|
||
var unitImages = SelectBatchEvidenceImages(batch.Units, evidenceImagesByUnit, maxImages);
|
||
var unitSemanticGraphJson = BuildJudgementBatchSemanticGraphJson(semanticGraphJson, batch.Units);
|
||
var result = await CallAsync(model, apiKey, baseUrl, apiMode, proxy, unitSemanticGraphJson, oneUnitRetrieval, outputDir, prefix, unitImages, maxImages);
|
||
var reportText = File.Exists(result.FinalReportPath) ? File.ReadAllText(result.FinalReportPath) : result.Message;
|
||
if (!IsNoIssueReport(reportText))
|
||
reports.Add((DiagnosticBranch(unit), $"### {SafeBatchHeading(batch.Units)}\n\n{reportText.Trim()}"));
|
||
rawSummaries.Add(new
|
||
{
|
||
batch_key = batch.Key,
|
||
unit_keys = batch.Units.Select(item => item.UnitKey).ToList(),
|
||
component_id = unit.ComponentId,
|
||
instance_name = unit.InstanceName,
|
||
diagnostic_stage = unit.DiagnosticStage,
|
||
knowledge_scope = unit.KnowledgeScope,
|
||
local_unit_count = batch.Units.Count,
|
||
candidate_count = batch.Units.Sum(item => item.Candidates.Count),
|
||
status = result.Status,
|
||
message = result.Message,
|
||
attached_images = unitImages.Select(image => image.Name).ToList(),
|
||
report_path = result.FinalReportPath,
|
||
raw_response_path = result.RawResponsePath
|
||
});
|
||
|
||
if (result.Status != "ok")
|
||
{
|
||
status = "error";
|
||
messages.Add($"{FirstNonEmpty(unit.UnitKey, unit.LocalId)}: {result.Message}");
|
||
}
|
||
|
||
processedUnits += batch.Units.Count;
|
||
onUnitCompleted?.Invoke(unit, processedUnits, total);
|
||
}
|
||
|
||
var combinedBody = reports.Where(item => !string.IsNullOrWhiteSpace(item.Text)).ToList();
|
||
var combinedReport = combinedBody.Count == 0
|
||
? "# 机械设计查错结果\n\n未发现明确违反零件知识库或装配体知识库禁忌的结构问题。"
|
||
: "# 机械设计查错结果\n\n" + BuildCombinedDiagnosticSections(combinedBody);
|
||
File.WriteAllText(combinedReportPath, combinedReport, new UTF8Encoding(false));
|
||
File.WriteAllText(combinedRawPath, JsonSerializer.Serialize(new
|
||
{
|
||
status,
|
||
message = messages.Count == 0 ? "AI final diagnostic report generated per local semantic unit." : string.Join("; ", messages),
|
||
units = rawSummaries
|
||
}, MechanicalJsonOptions.Default), new UTF8Encoding(false));
|
||
|
||
return new MechanicalFinalJudgeResult
|
||
{
|
||
Status = status,
|
||
Message = messages.Count == 0 ? "AI final diagnostic report generated per local semantic unit." : string.Join("; ", messages),
|
||
FinalReportPath = combinedReportPath,
|
||
RawResponsePath = combinedRawPath
|
||
};
|
||
}
|
||
|
||
static string SafeHeading(MechanicalRetrievedUnit unit)
|
||
{
|
||
return FirstNonEmpty($"{unit.InstanceName} {unit.LocalId}".Trim(), unit.UnitKey, unit.LocalId, unit.RetrievalSentence, "未命名局部单元")
|
||
.Replace("\r", " ")
|
||
.Replace("\n", " ")
|
||
.Trim();
|
||
}
|
||
|
||
static string SafeBatchHeading(IReadOnlyList<MechanicalRetrievedUnit> units)
|
||
{
|
||
if (units.Count == 1)
|
||
return SafeHeading(units[0]);
|
||
|
||
var first = units[0];
|
||
return FirstNonEmpty(first.InstanceName, first.ComponentId, "未命名零件") + $" 综合判定({units.Count} 个局部单元)";
|
||
}
|
||
|
||
static List<MechanicalJudgementBatch> BuildJudgementBatches(IReadOnlyList<MechanicalRetrievedUnit> units)
|
||
{
|
||
var batches = new List<MechanicalJudgementBatch>();
|
||
var groupedPartUnits = new Dictionary<string, MechanicalJudgementBatch>(StringComparer.OrdinalIgnoreCase);
|
||
|
||
foreach (var unit in units)
|
||
{
|
||
if (IsFunctionalGroupBatchUnit(unit))
|
||
{
|
||
batches.Add(new MechanicalJudgementBatch
|
||
{
|
||
Key = FirstNonEmpty(unit.UnitKey, unit.LocalId, "functional_group_unit"),
|
||
Units = [unit]
|
||
});
|
||
continue;
|
||
}
|
||
|
||
var owner = FirstNonEmpty(unit.ComponentId, unit.InstanceName, unit.UnitKey, unit.LocalId);
|
||
var key = $"{unit.DiagnosticStage}:{owner}";
|
||
if (!groupedPartUnits.TryGetValue(key, out var batch))
|
||
{
|
||
batch = new MechanicalJudgementBatch
|
||
{
|
||
Key = key,
|
||
Units = []
|
||
};
|
||
groupedPartUnits[key] = batch;
|
||
batches.Add(batch);
|
||
}
|
||
batch.Units.Add(unit);
|
||
}
|
||
|
||
return batches;
|
||
}
|
||
|
||
static bool IsFunctionalGroupBatchUnit(MechanicalRetrievedUnit unit)
|
||
{
|
||
if (unit.DiagnosticStage.Equals(MechanicalDiagnosticFlowTerms.StageFunctionalGroupCheck, StringComparison.OrdinalIgnoreCase) ||
|
||
unit.KnowledgeScope.Equals(MechanicalDiagnosticFlowTerms.KnowledgeScopeAssembly, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
var anchorType = unit.AnchorType.ToLowerInvariant();
|
||
return anchorType is "functional_group" or "interface";
|
||
}
|
||
|
||
static List<MechanicalEvidenceImage> SelectBatchEvidenceImages(
|
||
IReadOnlyList<MechanicalRetrievedUnit> units,
|
||
IReadOnlyDictionary<string, List<MechanicalEvidenceImage>> evidenceImagesByUnit,
|
||
int maxImages)
|
||
{
|
||
if (maxImages <= 0)
|
||
return [];
|
||
|
||
var selected = new List<MechanicalEvidenceImage>();
|
||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
|
||
foreach (var unit in units)
|
||
{
|
||
if (!evidenceImagesByUnit.TryGetValue(unit.UnitKey, out var images) || images.Count == 0)
|
||
continue;
|
||
Add(images[0]);
|
||
if (selected.Count >= maxImages)
|
||
return selected;
|
||
}
|
||
|
||
foreach (var unit in units)
|
||
{
|
||
if (!evidenceImagesByUnit.TryGetValue(unit.UnitKey, out var images))
|
||
continue;
|
||
foreach (var image in images)
|
||
{
|
||
Add(image);
|
||
if (selected.Count >= maxImages)
|
||
return selected;
|
||
}
|
||
}
|
||
|
||
return selected;
|
||
|
||
void Add(MechanicalEvidenceImage image)
|
||
{
|
||
var key = FirstNonEmpty(image.FilePath, image.Url, image.Name);
|
||
if (seen.Add(key))
|
||
selected.Add(image);
|
||
}
|
||
}
|
||
|
||
static JsonNode? BuildCrossUnitEvidenceSummary(IReadOnlyList<MechanicalRetrievedUnit> units)
|
||
{
|
||
var holes = units
|
||
.Where(unit =>
|
||
unit.PatternType.Contains("hole", StringComparison.OrdinalIgnoreCase) ||
|
||
ReadStringArrayProperty(unit.FactBundle, "roles").Any(role => role.Contains("hole", StringComparison.OrdinalIgnoreCase)))
|
||
.Select(unit =>
|
||
{
|
||
var found = TryReadNumericProperty(unit.FactBundle, "diameter_mm", out var diameter);
|
||
return new { Unit = unit, Found = found, Diameter = diameter };
|
||
})
|
||
.Where(item => item.Found && item.Diameter > 0)
|
||
.Select(item => new MechanicalHoleDiameterEvidence
|
||
{
|
||
Unit = item.Unit,
|
||
DiameterMm = item.Diameter,
|
||
Roles = ReadStringArrayProperty(item.Unit.FactBundle, "roles")
|
||
.Where(role => !string.IsNullOrWhiteSpace(role))
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.ToList()
|
||
})
|
||
.OrderBy(item => item.DiameterMm)
|
||
.ThenBy(item => item.Unit.AnchorId, StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
|
||
var clusters = new List<List<MechanicalHoleDiameterEvidence>>();
|
||
foreach (var hole in holes)
|
||
{
|
||
var cluster = clusters.LastOrDefault();
|
||
if (cluster == null || !DiametersAreSimilar(cluster[0].DiameterMm, hole.DiameterMm))
|
||
{
|
||
cluster = [];
|
||
clusters.Add(cluster);
|
||
}
|
||
cluster.Add(hole);
|
||
}
|
||
|
||
var similarClusters = clusters
|
||
.Where(cluster => cluster.Select(item => item.DiameterMm).Distinct().Count() >= 2)
|
||
.Select(cluster =>
|
||
{
|
||
var sharedRoles = cluster
|
||
.Select(item => item.Roles.ToHashSet(StringComparer.OrdinalIgnoreCase))
|
||
.Aggregate((left, right) =>
|
||
{
|
||
left.IntersectWith(right);
|
||
return left;
|
||
})
|
||
.OrderBy(role => role, StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
var minimum = cluster.Min(item => item.DiameterMm);
|
||
var maximum = cluster.Max(item => item.DiameterMm);
|
||
var hasSharedHoleRole = sharedRoles.Any(role => role.Contains("hole", StringComparison.OrdinalIgnoreCase) ||
|
||
role.Contains("fasten", StringComparison.OrdinalIgnoreCase));
|
||
return new
|
||
{
|
||
diameters_mm = cluster.Select(item => item.DiameterMm).Distinct().Order().ToList(),
|
||
diameter_span_mm = Math.Round(maximum - minimum, 3),
|
||
hole_groups = cluster.Select(item => new
|
||
{
|
||
unit_key = item.Unit.UnitKey,
|
||
local_id = item.Unit.LocalId,
|
||
anchor_id = item.Unit.AnchorId,
|
||
diameter_mm = item.DiameterMm,
|
||
roles = item.Roles,
|
||
image_evidence_names = item.Unit.ImageEvidenceNames
|
||
}).ToList(),
|
||
shared_roles = sharedRoles,
|
||
same_tool_size_possibility = hasSharedHoleRole,
|
||
requires_function_difference_check = true,
|
||
related_rule_id = "18.2.8",
|
||
judgement_basis = "These diameters belong to different hole groups on the same part and differ by no more than 1 mm and 20 percent. Compare their actual functions before accepting separate drill sizes."
|
||
};
|
||
})
|
||
.ToList();
|
||
|
||
return JsonSerializer.SerializeToNode(new
|
||
{
|
||
purpose = "Integrated evidence across all local feature groups owned by the current part.",
|
||
local_unit_count = units.Count,
|
||
hole_diameter_inventory = holes.Select(item => new
|
||
{
|
||
unit_key = item.Unit.UnitKey,
|
||
anchor_id = item.Unit.AnchorId,
|
||
diameter_mm = item.DiameterMm,
|
||
roles = item.Roles
|
||
}).ToList(),
|
||
similar_hole_diameter_clusters = similarClusters,
|
||
comparison_policy = "Do not judge each hole group in isolation. For rule 18.2.8, compare all same-part hole groups; separate tool sizes are justified only by a supported functional difference."
|
||
}, MechanicalJsonOptions.Default);
|
||
}
|
||
|
||
static bool DiametersAreSimilar(double left, double right)
|
||
{
|
||
var minimum = Math.Min(left, right);
|
||
var span = Math.Abs(left - right);
|
||
return minimum > 0 && span <= 1.0 + 1e-9 && span / minimum <= 0.20 + 1e-9;
|
||
}
|
||
|
||
static bool TryReadNumericProperty(JsonElement element, string propertyName, out double value)
|
||
{
|
||
value = 0;
|
||
if (element.ValueKind == JsonValueKind.Object)
|
||
{
|
||
foreach (var property in element.EnumerateObject())
|
||
{
|
||
if (property.Name.Equals(propertyName, StringComparison.OrdinalIgnoreCase) &&
|
||
property.Value.ValueKind == JsonValueKind.Number &&
|
||
property.Value.TryGetDouble(out value))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
if (TryReadNumericProperty(property.Value, propertyName, out value))
|
||
return true;
|
||
}
|
||
}
|
||
else if (element.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var item in element.EnumerateArray())
|
||
{
|
||
if (TryReadNumericProperty(item, propertyName, out value))
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
static List<string> ReadStringArrayProperty(JsonElement element, string propertyName)
|
||
{
|
||
if (element.ValueKind == JsonValueKind.Object)
|
||
{
|
||
foreach (var property in element.EnumerateObject())
|
||
{
|
||
if (property.Name.Equals(propertyName, StringComparison.OrdinalIgnoreCase) &&
|
||
property.Value.ValueKind == JsonValueKind.Array)
|
||
{
|
||
return property.Value.EnumerateArray()
|
||
.Where(item => item.ValueKind == JsonValueKind.String)
|
||
.Select(item => item.GetString() ?? "")
|
||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||
.ToList();
|
||
}
|
||
|
||
var nested = ReadStringArrayProperty(property.Value, propertyName);
|
||
if (nested.Count > 0)
|
||
return nested;
|
||
}
|
||
}
|
||
else if (element.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var item in element.EnumerateArray())
|
||
{
|
||
var nested = ReadStringArrayProperty(item, propertyName);
|
||
if (nested.Count > 0)
|
||
return nested;
|
||
}
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
static string DiagnosticBranch(MechanicalRetrievedUnit unit)
|
||
{
|
||
if (unit.DiagnosticStage.Equals(MechanicalDiagnosticFlowTerms.StageFunctionalGroupCheck, StringComparison.OrdinalIgnoreCase) ||
|
||
unit.KnowledgeScope.Equals(MechanicalDiagnosticFlowTerms.KnowledgeScopeAssembly, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return "functional_group";
|
||
}
|
||
|
||
var anchorType = unit.AnchorType.ToLowerInvariant();
|
||
return anchorType is "functional_group" or "interface"
|
||
? "functional_group"
|
||
: "part";
|
||
}
|
||
|
||
static string BuildCombinedDiagnosticSections(IReadOnlyList<(string Branch, string Text)> reports)
|
||
{
|
||
var sections = new List<string>();
|
||
var functionalGroupReports = reports
|
||
.Where(item => item.Branch.Equals("functional_group", StringComparison.OrdinalIgnoreCase))
|
||
.Select(item => item.Text)
|
||
.ToList();
|
||
if (functionalGroupReports.Count > 0)
|
||
sections.Add("## 功能组设计问题\n\n" + string.Join("\n\n", functionalGroupReports));
|
||
|
||
var partReports = reports
|
||
.Where(item => !item.Branch.Equals("functional_group", StringComparison.OrdinalIgnoreCase))
|
||
.Select(item => item.Text)
|
||
.ToList();
|
||
if (partReports.Count > 0)
|
||
sections.Add("## 零件自身设计问题\n\n" + string.Join("\n\n", partReports));
|
||
|
||
return string.Join("\n\n", sections);
|
||
}
|
||
|
||
static string BuildJudgementBatchSemanticGraphJson(
|
||
string semanticGraphJson,
|
||
IReadOnlyList<MechanicalRetrievedUnit> units)
|
||
{
|
||
var unit = units[0];
|
||
try
|
||
{
|
||
using var doc = JsonDocument.Parse(semanticGraphJson);
|
||
var root = doc.RootElement;
|
||
var relatedComponentIds = units
|
||
.SelectMany(ExtractRelatedComponentIds)
|
||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||
var localUnitNodes = new JsonArray();
|
||
var linkedSubgraphIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
|
||
if (root.TryGetProperty("local_semantic_units", out var localUnits) && localUnits.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var item in localUnits.EnumerateArray())
|
||
{
|
||
if (!units.Any(current => IsCurrentUnit(item, current)))
|
||
continue;
|
||
|
||
AddObjectClone(localUnitNodes, item);
|
||
AddIfNotEmpty(linkedSubgraphIds, ReadString(item, "linked_subgraph_id"));
|
||
AddComponentIdsFromElement(item, relatedComponentIds);
|
||
}
|
||
}
|
||
|
||
if (localUnitNodes.Count == 0)
|
||
{
|
||
foreach (var current in units)
|
||
localUnitNodes.Add(JsonSerializer.SerializeToNode(current, MechanicalJsonOptions.Default));
|
||
}
|
||
|
||
var subgraphNodes = new JsonArray();
|
||
if (root.TryGetProperty("local_subgraphs", out var subgraphs) && subgraphs.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var item in subgraphs.EnumerateArray())
|
||
{
|
||
var id = ReadString(item, "id");
|
||
if (!linkedSubgraphIds.Contains(id) && !ElementReferencesAnyComponent(item, relatedComponentIds))
|
||
continue;
|
||
|
||
AddObjectClone(subgraphNodes, item);
|
||
AddComponentIdsFromElement(item, relatedComponentIds);
|
||
}
|
||
}
|
||
|
||
var componentNodes = new JsonArray();
|
||
if (root.TryGetProperty("nodes", out var nodes) && nodes.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var item in nodes.EnumerateArray())
|
||
{
|
||
var id = ReadString(item, "id");
|
||
if (relatedComponentIds.Contains(id))
|
||
AddObjectClone(componentNodes, item);
|
||
}
|
||
}
|
||
|
||
var isolated = new JsonObject
|
||
{
|
||
["schema"] = "mechanical_semantic_graph_per_unit_context_v1",
|
||
["source_schema"] = ReadString(root, "schema"),
|
||
["context_policy"] = "All local semantic units owned by the same part and diagnostic stage are integrated for cross-feature final judgement.",
|
||
["target_unit_keys"] = JsonSerializer.SerializeToNode(units.Select(item => item.UnitKey).ToList(), MechanicalJsonOptions.Default),
|
||
["target_component_id"] = unit.ComponentId,
|
||
["target_instance_name"] = unit.InstanceName,
|
||
["related_component_ids"] = JsonSerializer.SerializeToNode(relatedComponentIds.Order(StringComparer.OrdinalIgnoreCase).ToList(), MechanicalJsonOptions.Default),
|
||
["cross_unit_evidence_summary"] = BuildCrossUnitEvidenceSummary(units),
|
||
["nodes"] = componentNodes,
|
||
["local_semantic_units"] = localUnitNodes,
|
||
["local_subgraphs"] = subgraphNodes
|
||
};
|
||
|
||
return isolated.ToJsonString(MechanicalJsonOptions.Default);
|
||
}
|
||
catch
|
||
{
|
||
var fallback = new
|
||
{
|
||
schema = "mechanical_semantic_graph_per_unit_context_v1",
|
||
context_policy = "Fallback context: all retrieved local units in the current part judgement batch are included because the source graph could not be parsed.",
|
||
target_unit_keys = units.Select(item => item.UnitKey).ToList(),
|
||
target_component_id = unit.ComponentId,
|
||
target_instance_name = unit.InstanceName,
|
||
related_component_ids = units.SelectMany(ExtractRelatedComponentIds).Distinct(StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase).ToList(),
|
||
cross_unit_evidence_summary = BuildCrossUnitEvidenceSummary(units),
|
||
local_semantic_units = units
|
||
};
|
||
return JsonSerializer.Serialize(fallback, MechanicalJsonOptions.Default);
|
||
}
|
||
}
|
||
|
||
static bool IsCurrentUnit(JsonElement item, MechanicalRetrievedUnit unit)
|
||
{
|
||
var itemUnitKey = ReadString(item, "unit_key");
|
||
if (!string.IsNullOrWhiteSpace(unit.UnitKey) &&
|
||
string.Equals(itemUnitKey, unit.UnitKey, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
var dedupKey = ReadString(item, "dedup_key");
|
||
if (!string.IsNullOrWhiteSpace(unit.DedupKey) &&
|
||
string.Equals(dedupKey, unit.DedupKey, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
var anchorId = ReadString(item, "anchor_id");
|
||
if (!string.IsNullOrWhiteSpace(unit.AnchorId) &&
|
||
string.Equals(anchorId, unit.AnchorId, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
var localId = FirstNonEmpty(ReadString(item, "local_id"), ReadString(item, "id"));
|
||
var itemComponentId = ExtractComponentId(item);
|
||
if (!string.IsNullOrWhiteSpace(unit.LocalId) &&
|
||
string.Equals(localId, unit.LocalId, StringComparison.OrdinalIgnoreCase) &&
|
||
(string.IsNullOrWhiteSpace(unit.ComponentId) ||
|
||
string.IsNullOrWhiteSpace(itemComponentId) ||
|
||
string.Equals(itemComponentId, unit.ComponentId, StringComparison.OrdinalIgnoreCase)))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
static string ExtractComponentId(JsonElement element)
|
||
{
|
||
if (element.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||
return "";
|
||
|
||
if (element.ValueKind == JsonValueKind.Object)
|
||
{
|
||
if (element.TryGetProperty("component_id", out var componentId) &&
|
||
componentId.ValueKind == JsonValueKind.String)
|
||
{
|
||
return componentId.GetString() ?? "";
|
||
}
|
||
|
||
foreach (var property in element.EnumerateObject())
|
||
{
|
||
var nested = ExtractComponentId(property.Value);
|
||
if (!string.IsNullOrWhiteSpace(nested))
|
||
return nested;
|
||
}
|
||
}
|
||
else if (element.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var item in element.EnumerateArray())
|
||
{
|
||
var nested = ExtractComponentId(item);
|
||
if (!string.IsNullOrWhiteSpace(nested))
|
||
return nested;
|
||
}
|
||
}
|
||
|
||
return "";
|
||
}
|
||
|
||
static HashSet<string> ExtractRelatedComponentIds(MechanicalRetrievedUnit unit)
|
||
{
|
||
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
AddComponentIdsFromText(unit.ComponentId, ids);
|
||
AddComponentIdsFromText(unit.UnitKey, ids);
|
||
AddComponentIdsFromText(unit.LocalId, ids);
|
||
AddComponentIdsFromText(unit.AnchorId, ids);
|
||
AddComponentIdsFromText(unit.RetrievalSentence, ids);
|
||
AddComponentIdsFromText(unit.DedupKey, ids);
|
||
AddComponentIdsFromElement(unit.FactBundle, ids);
|
||
return ids;
|
||
}
|
||
|
||
static void AddComponentIdsFromElement(JsonElement element, HashSet<string> ids)
|
||
{
|
||
if (element.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||
return;
|
||
|
||
if (element.ValueKind == JsonValueKind.Object)
|
||
{
|
||
if (element.TryGetProperty("component_id", out var componentId))
|
||
AddComponentIdsFromText(componentId.GetString(), ids);
|
||
if (element.TryGetProperty("components", out var components) && components.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var component in components.EnumerateArray())
|
||
AddComponentIdsFromText(component.GetString(), ids);
|
||
}
|
||
}
|
||
|
||
AddComponentIdsFromText(element.GetRawText(), ids);
|
||
}
|
||
|
||
static bool ElementReferencesAnyComponent(JsonElement element, IReadOnlySet<string> componentIds)
|
||
{
|
||
if (componentIds.Count == 0)
|
||
return false;
|
||
|
||
var text = element.GetRawText();
|
||
return componentIds.Any(id => text.Contains(id, StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
|
||
static void AddComponentIdsFromText(string? text, HashSet<string> ids)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(text))
|
||
return;
|
||
|
||
foreach (Match match in ComponentTokenRegex().Matches(text))
|
||
ids.Add(Regex.Replace(match.Value.ToLowerInvariant(), @"[-\s]+", "_"));
|
||
}
|
||
|
||
[GeneratedRegex(@"\bcomponent[_\-\s]*\d+\b|\bpart[_\-\s]*\d+\b", RegexOptions.IgnoreCase)]
|
||
private static partial Regex ComponentTokenRegex();
|
||
|
||
static void AddIfNotEmpty(HashSet<string> values, string value)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(value))
|
||
values.Add(value);
|
||
}
|
||
|
||
static void AddObjectClone(JsonArray array, JsonElement element)
|
||
{
|
||
var node = JsonNode.Parse(element.GetRawText());
|
||
if (node != null)
|
||
array.Add(node);
|
||
}
|
||
|
||
static string ReadString(JsonElement element, string property)
|
||
{
|
||
if (element.ValueKind != JsonValueKind.Object ||
|
||
!element.TryGetProperty(property, out var value) ||
|
||
value.ValueKind != JsonValueKind.String)
|
||
{
|
||
return "";
|
||
}
|
||
|
||
return value.GetString() ?? "";
|
||
}
|
||
|
||
static string FirstNonEmpty(params string[] values)
|
||
{
|
||
return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? "";
|
||
}
|
||
|
||
static string SanitizeFileName(string value)
|
||
{
|
||
var invalid = Path.GetInvalidFileNameChars().ToHashSet();
|
||
var cleaned = new string(value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray());
|
||
cleaned = Regex.Replace(cleaned, @"\s+", "_").Trim('_');
|
||
return string.IsNullOrWhiteSpace(cleaned) ? "unit" : cleaned;
|
||
}
|
||
|
||
static bool IsNoIssueReport(string report)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(report))
|
||
return true;
|
||
|
||
var text = report.Trim().ToLowerInvariant();
|
||
if (text.Contains("needs_review") ||
|
||
text.Contains("confirmed problem") ||
|
||
text.Contains("判定:problem") ||
|
||
text.Contains("判定: problem") ||
|
||
text.Contains("判定:问题") ||
|
||
text.Contains("明确违反") && !text.Contains("未发现明确违反"))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return text.Contains("未发现明确违反第18章禁忌的结构问题") ||
|
||
text.Contains("未发现明确违反第18章或第19章禁忌的结构问题") ||
|
||
text.Contains("未发现明确违反零件知识库或装配体知识库禁忌的结构问题") ||
|
||
text.Contains("no confirmed violation") ||
|
||
text.Contains("no clear violation");
|
||
}
|
||
|
||
static string BuildPrompt(string semanticGraphJson, MechanicalKnowledgeRetrievalResult retrieval, IReadOnlyList<MechanicalEvidenceImage> evidenceImages)
|
||
{
|
||
var retrievalJson = BuildCompactJudgementRetrievalJson(retrieval);
|
||
if (semanticGraphJson.Length > 90000)
|
||
semanticGraphJson = semanticGraphJson[..90000] + "\n...TRUNCATED; full graph is in mechanical_semantic_graph.json...";
|
||
if (retrievalJson.Length > 90000)
|
||
retrievalJson = retrievalJson[..90000] + "\n...TRUNCATED; full retrieval candidates are in knowledge_retrieval_candidates.json...";
|
||
var evidenceImageList = evidenceImages.Count == 0
|
||
? "无附加截图。"
|
||
: string.Join("\n", evidenceImages.Select((image, index) => $"{index + 1}. {image.Name}"));
|
||
|
||
var reportInstructions = """
|
||
你是机械设计查错专家。请输出给设计者直接阅读的中文 Markdown,不要输出调试字段、文件路径或 JSON 摘要。
|
||
|
||
判定要求:
|
||
- 对每个局部语义单元和候选规则逐条比较。
|
||
- 候选规则只是超过检索阈值后进入判定的规则,不代表已经有错。
|
||
- inspection_card_targeted_lookup 只表示局部子图生成阶段曾定向核查该规则,需要回查完整原始规则;它本身不是错误证据。
|
||
- 不要只因为检索到规则就判错;必须判断 B-rep/截图证据是否满足 verification_rule.required_model_facts、bad_if、good_if。
|
||
- 如果 required_model_facts 在语义图里没有直接写出,不要立刻判“缺少信息”;必须先按顺序查找或推断:fact_bundle、B-rep 几何/测量、接触关系、SolidWorks 配合关系、附加截图、AI 语义推断。
|
||
- 推理时区分证据类型:brep_observed、measurement_observed、contact_observed、mate_observed、image_inference、ai_inferred、unsupported。只有前六类证据能支持 bad_if 时才输出 problem;仍无法支持 bad_if/good_if 时才输出 needs_review。
|
||
- 缺失信息处理协议:对每一个缺失的 required_model_fact,先分类为 direct_observed、computable_from_brep、inferable_from_assembly、inferable_from_image、mechanical_common_sense_inference、drawing_or_process_document_only、unsupported。前五类必须先补证据或推断再判定;drawing_or_process_document_only 若是规则必需事实则跳过该候选;unsupported 只有在已有明确可疑几何/接触/配合迹象时才允许 needs_review。
|
||
- AI 语义推断不是可选项。凡是“是否需要加工/精加工、是否可能铸造/锻造/焊接、是否为定位/支撑/密封/导向/承力/维护/安全相关结构、是否有装配可达性或运动干涉风险、是否属于标准件上下文”等机械语义,若 B-rep 没有直接标签,必须先用机械设计常识结合当前三维证据推断,再判断规则是否适用。
|
||
- 对装配体输入,判断某个零件的局部结构问题时,必须先读取该零件在装配体中的 assembly_function_context:零件功能、关联组件、接触/配合面功能、承载/定位/支撑/密封/导向/紧固/运动限制/可达性角色。不要把装配体中的零件当作孤立未知零件分析。
|
||
- 分析整机/装配体功能时,必须把零件和零件之间的配合关系作为主要证据:SolidWorks mates、B-rep 接触、同轴圆柱关系、平面贴合/支撑/定位关系、相对位置和连接路径。不要只根据零件名称或图片猜功能。
|
||
- 对电机、伺服电机、马达、减速器、减速机、gearbox、reducer、gear motor 等外购件,只确认其外购件身份和装配功能;不要对其内部结构或普通零件设计禁忌做判定,除非候选规则明确是外购件选型、安装、接口、配合或维护可达性问题。
|
||
- 如果三维证据、装配关系和图片仍不能直接给出某个工程属性,也不要立刻放弃;必须进入 mechanical_common_sense_inference:先回答“按机械设计常识,什么样的零件/结构通常应该具备这个属性、功能或工艺要求”,再判断当前零件是否大概率属于这种情况。这个判断可以作为 ai_inferred 低/中置信证据参与判定。
|
||
- 做 ai_inferred 推断时,内部必须先回答“通常什么样的结构需要这个属性/功能/工艺要求”,再对照当前零件的几何、装配、接触、配合、截图和机械常识判断是否满足。不要把“局部子图未明确标识”“未提供工艺标签”“未提供粗糙度信息”作为直接 needs_review 理由。
|
||
- 对“加工面/非加工面/铸件候选/功能角色”这类不能从 B-rep 或配合关系直接读取、但可以由三维几何和装配关系判断的事实,必须先用机械常识做 ai_inferred 推断,并写出依据:几何复杂度、壳体/支架/底座/端盖形态、腔体/筋板/凸台/安装耳、接触/配合、装配位置或截图。
|
||
- 铸件候选推断原则:复杂箱体、壳体、支架、底座、端盖、阀体/泵体类零件,具有复杂外形、腔体、筋板、凸台、安装耳、大面积非功能外表面且只需要局部功能面加工时,可作为 cast_part_candidate;轴、销、螺钉、螺母、垫圈、轴承、丝杠、导轨、标准件、简单回转件默认不要按铸件处理,除非证据明确支持。
|
||
- 精加工/加工面候选推断原则:与其他零件接触或配合的面、定位/支撑/密封/轴承座/安装基准/运动导向面,通常可推断为 machined_surface_candidate 或 precision_surface_candidate;大面积外形面、非接触外表面、壳体粗糙外表面通常只能推断为 unmachined_surface_candidate。
|
||
- 三维模型信息边界:B-rep/三维截图通常能提供形状、尺寸、相对位置、接触/配合、可达性、功能和工艺候选推断;通常不能提供二维图纸中的具体粗糙度数值、公差等级、形位公差、热处理、材料牌号、技术要求文字。若某条规则必须依赖这些二维图纸/工艺文件中的具体数值或文字,且当前输入没有这些信息,则判为当前三维证据不可判定并跳过,不要输出 problem 或 needs_review。
|
||
- 如果规则只是要求判断“某处是否应作为加工面/精加工面/非加工面边界”,不能因为没有粗糙度数值就跳过;应先根据接触、配合、功能和几何关系推断是否需要加工或精加工,再判定结构是否可疑。
|
||
- 如果 B-rep 或截图推理支持 good_if 或 not_applicable,就不要输出该候选规则。
|
||
- 只输出和当前候选规则直接相关的 problem 或高价值 needs_review;pass、not_applicable、未发现问题、泛泛缺资料的候选规则一律不要写。
|
||
- needs_review 只能用于“已有具体几何/接触/配合/图片迹象支持该条禁忌可能相关,且缺失事实可以通过补充三维/B-rep/装配证据解决”的情况;如果缺的是二维图纸专属信息,或只是缺少工艺标签、阈值、粗糙度/公差数值且没有明确可疑几何关系,不要输出。
|
||
- 如果没有明确违反禁忌的结构问题,只输出一句:未发现明确违反零件知识库或装配体知识库禁忌的结构问题。
|
||
- problem 或 needs_review 必须写清:问题部位、对应禁忌条目/规则编号、为什么有问题、建议怎么改。
|
||
- “为什么错”必须基于补证据或推断后的结论来写,例如“按接触/配合关系可推断该面是定位或加工候选面”,不能只写“局部子图未说明某属性所以无法判断”。
|
||
- 引用截图时用视图名称或证据名称描述,例如“组件上下文截图”“等轴测截图”“front/top/right 视图”;不要写本地文件路径。
|
||
- 不要输出“本次附加截图证据”“附图列表”“候选规则列表”“检索分数”等调试信息。
|
||
- 不要把 sketch_edge_1、edge_12、face_3、patch_r0_c1、region_outside 这类原始 B-rep ID 当成问题位置写给用户;它们只能作为内部追溯证据。用户可读的问题位置必须是机械语义描述。
|
||
|
||
请按这个结构输出:
|
||
# 机械设计查错结果
|
||
每条问题使用:
|
||
- 判定:
|
||
- 对应禁忌:
|
||
- 问题部位:
|
||
- 为什么错:
|
||
- 建议:
|
||
每条问题必须是独立的 5 行,不要把多条禁忌写进同一个段落。不要输出“通过项”“未发现问题的规则”“不适用规则”或长篇总结。
|
||
""";
|
||
|
||
return $"""
|
||
{reportInstructions}
|
||
|
||
你是机械设计查错专家。你将收到三类输入:
|
||
1. AI 已根据 SolidWorks B-rep 和图片证据逐零件生成的局部子图集合。
|
||
2. 后端按局部语义单元检索到的机械设计禁忌知识库候选规则。
|
||
3. 与当前局部语义单元匹配的零件图、装配体高亮图或内部组件上下文图。
|
||
|
||
请完成最终判定撰写:
|
||
- 对每个局部子图和候选规则逐项比较。
|
||
- 如果规则需要尺寸、直径、间隙、面积、接触、方向等事实,优先使用局部子图和 fact_bundle 中已有的 B-rep 事实。
|
||
- 如果局部语义没有直接给出某项事实,必须按这个顺序补证据:fact_bundle -> B-rep 参数/测量 -> 接触关系 -> SolidWorks 配合关系 -> 附加截图 -> AI 语义推断。
|
||
- 最终判定必须区分 brep_observed、measurement_observed、contact_observed、mate_observed、image_inference、ai_inferred、unsupported;不要把“未显式写出”直接等同于“无法判断”。
|
||
- 对任何缺失事实,必须先执行缺失信息处理协议:direct_observed 直接用;computable_from_brep 从几何/测量推;inferable_from_assembly 从接触/配合/位置推;inferable_from_image 从截图推;mechanical_common_sense_inference 用机械设计常识判断“当前结构应不应该具备该属性/功能/工艺要求”;drawing_or_process_document_only 且规则必须依赖具体数值/文字时跳过;unsupported 只有明确可疑结构时才 needs_review。
|
||
- 不允许把“局部子图未明确标识”“没有工艺标签”“没有粗糙度/公差信息”作为最终理由直接输出。最终理由必须写成补证据或推断后的机械判断;如果无法从当前三维证据推断且属于二维图纸/BOM/工艺文件专属信息,则不输出该候选。
|
||
- 如果缺失信息能够从 B-rep、配合关系、图片或机械常识中补出,就先补出再判定;只有这些信息源仍无法支持或反驳 bad_if/good_if,且补充三维/B-rep/装配证据可以解决时,才写 needs_review。
|
||
- 当 B-rep/图片无法直接证明“是不是某种工艺/功能属性”时,仍要先用机械常识判断“应不应该是”:例如不知道是否铸造,不是判缺资料,而是判断该零件按形态、复杂度和用途是否通常应该采用铸造或铸造毛坯;不知道是否需要精加工,不是判缺粗糙度,而是判断该面按接触/定位/运动/密封功能是否通常需要精加工。
|
||
- 对铸件候选、加工面、非加工面、精加工候选、功能角色这类语义,不要因为局部子图没有显式标签就判缺资料;必须先依据三维几何、装配接触/配合、零件类型和截图做 ai_inferred 推断。
|
||
- 对装配体输入,零件级规则判定必须优先使用局部子图 fact_bundle.assembly_function_context 中的零件功能和配合面功能;例如同一个平面在孤立零件中只是普通平面,但在装配体中若承担定位/支撑/密封/安装基准,就应按功能面参与规则判断。
|
||
- 若 fact_bundle.assembly_function_context 中包含 mate/contact/coaxial/flush/support/locating 关系,必须把这些零件间关系作为判断“该面/孔/凸台/轴承座是否为功能结构”的直接依据。
|
||
- 若 fact_bundle 或 component profile 表明该组件是电机、减速器等外购件,零件内部加工、铸造、孔槽、筋板、局部结构禁忌规则默认不适用;只保留与装配接口、安装定位、外部可达性、选型参数相关的候选。
|
||
- 对其他机械语义同样适用:定位、支撑、承力、密封、导向、运动副、装配可达性、维护空间、安全防护、润滑/排水/排气、标准件上下文、工艺经济性、是否需要局部加工或精加工,都必须先根据结构常识和当前证据推断,不能只等局部子图显式写出。
|
||
- 铸件候选的常识判断:复杂壳体/箱体/支架/底座/端盖,带腔体、筋板、凸台、安装耳、大面积非功能外表面,且只需要局部功能面加工,通常可作为铸件候选;轴、销、螺钉、垫圈、轴承、丝杠、导轨、标准件、简单回转件通常不作为铸件候选。
|
||
- 加工/精加工候选的常识判断:接触面、配合面、定位面、支撑面、密封面、轴承座、安装基准、运动导向面通常需要加工或精加工;大面积非接触外表面通常只是非加工候选。
|
||
- 对粗糙度具体数值、公差等级、形位公差、热处理、材料牌号、技术要求文字等二维图纸/工艺文件专属信息,当前三维模型和 B-rep 通常不能提供。若候选规则必须依赖这些具体数值或文字才能判定,则跳过该候选,不要输出 needs_review;只有规则可通过三维结构和功能关系判定时才继续。
|
||
- 如果同一规则在多个不同零件实例或局部位置命中,请分别判断;不要因为规则编号重复就合并掉不同位置的问题。
|
||
- 当前输入若包含 cross_unit_evidence_summary,必须把它作为同一零件各特征分组的综合 B-rep/语义证据使用,不能只判断当前某一个孔组、面组或特征组。
|
||
- 对 18.2.8“减少加工同一零件所用刀具数(一)”,必须比较 cross_unit_evidence_summary.hole_diameter_inventory 和 similar_hole_diameter_clusters。若同一零件存在相差不超过 1 mm 且不超过较小直径 20% 的孔径簇,并且这些孔共享紧固孔/普通孔等功能角色、没有直接证据证明必须采用不同规格,则应输出 problem 或 needs_review;不得凭空假设它们分别对应 M4/M5/M6 等标准紧固件来排除。
|
||
- 三维零件查错的附图只应是 part_feature_highlight_view;装配体功能组判定只应是 assembly_physical_interface_view;装配体组内零件判定只应是 functional_group_all_face_highlight。若附图类型与当前阶段不一致,不得使用该图推理。
|
||
- 如果代码/事实已经给出大小关系或测量值,请基于这些事实写出人可读的判定理由。
|
||
- 不要只因为检索到规则就判错;必须说明证据是否满足 verification_rule.required_model_facts、bad_if、good_if。
|
||
- 输出中文 Markdown。
|
||
- 最终报告必须简洁:只写 confirmed problem 或高价值 needs_review。不要写 pass、not_applicable、通过项、无问题项、泛泛缺资料项。
|
||
- 如果没有明确问题,只输出一句:未发现明确违反零件知识库或装配体知识库禁忌的结构问题。
|
||
- 对经过 B-rep/接触/配合/截图/AI 推断补充后仍缺少关键事实但确实可疑的项,用 needs_review,最多写一句还缺什么可由三维/B-rep/装配补充的事实;不要把二维图纸专属信息缺失的候选写成 needs_review。
|
||
- 每个问题最多 5 行,且必须只对应一个禁忌条目。
|
||
|
||
建议输出结构:
|
||
# 机械设计查错结果
|
||
## 问题列表
|
||
|
||
局部子图集合:
|
||
```json
|
||
{semanticGraphJson}
|
||
```
|
||
|
||
知识库检索候选:
|
||
```json
|
||
{retrievalJson}
|
||
```
|
||
|
||
附加截图证据名称,仅用于内部判定引用,禁止原样输出为列表:
|
||
{evidenceImageList}
|
||
""";
|
||
}
|
||
|
||
static string BuildCompactJudgementRetrievalJson(MechanicalKnowledgeRetrievalResult retrieval)
|
||
{
|
||
var compact = new
|
||
{
|
||
retrieval.Schema,
|
||
retrieval.KnowledgeScope,
|
||
units = retrieval.Units.Select(unit => new
|
||
{
|
||
unit.UnitKey,
|
||
unit.ComponentId,
|
||
unit.InstanceName,
|
||
unit.LocalId,
|
||
unit.DiagnosticStage,
|
||
unit.KnowledgeScope,
|
||
unit.RetrievalSentence,
|
||
unit.AnchorType,
|
||
unit.AnchorId,
|
||
unit.PrimaryView,
|
||
unit.PatternType,
|
||
unit.ImageEvidenceNames,
|
||
unit.FactBundle,
|
||
candidates = unit.Candidates.Select(candidate => new
|
||
{
|
||
candidate.RuleId,
|
||
candidate.Code,
|
||
candidate.Title,
|
||
candidate.PrimaryView,
|
||
candidate.RuleRetrievalSentence,
|
||
candidate.Score,
|
||
candidate.ScoreBreakdown,
|
||
candidate.SelectionReason,
|
||
verification_rule = ReadOptionalRuleDetailProperty(candidate.RuleDetail, "verification_rule"),
|
||
diagnosis_output = ReadOptionalRuleDetailProperty(candidate.RuleDetail, "diagnosis_output")
|
||
}).ToList()
|
||
}).ToList()
|
||
};
|
||
return JsonSerializer.Serialize(compact, MechanicalJsonOptions.Default);
|
||
}
|
||
|
||
static JsonElement? ReadOptionalRuleDetailProperty(JsonElement detail, string propertyName)
|
||
{
|
||
if (detail.ValueKind == JsonValueKind.Object &&
|
||
detail.TryGetProperty(propertyName, out var property))
|
||
{
|
||
return property.Clone();
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
static string ExtractResponseText(string raw)
|
||
{
|
||
using var doc = JsonDocument.Parse(raw);
|
||
if (doc.RootElement.TryGetProperty("output_text", out var outputText) && outputText.ValueKind == JsonValueKind.String)
|
||
return outputText.GetString() ?? "";
|
||
|
||
var parts = new List<string>();
|
||
if (doc.RootElement.TryGetProperty("choices", out var choices) && choices.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var choice in choices.EnumerateArray())
|
||
{
|
||
if (!choice.TryGetProperty("message", out var message))
|
||
continue;
|
||
if (message.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.String)
|
||
parts.Add(content.GetString() ?? "");
|
||
}
|
||
if (parts.Count > 0)
|
||
return string.Join(Environment.NewLine, parts);
|
||
}
|
||
|
||
if (doc.RootElement.TryGetProperty("output", out var output) && output.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var item in output.EnumerateArray())
|
||
{
|
||
if (!item.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array)
|
||
continue;
|
||
foreach (var c in content.EnumerateArray())
|
||
{
|
||
if (c.TryGetProperty("text", out var text) && text.ValueKind == JsonValueKind.String)
|
||
parts.Add(text.GetString() ?? "");
|
||
}
|
||
}
|
||
}
|
||
|
||
return parts.Count > 0 ? string.Join(Environment.NewLine, parts) : raw;
|
||
}
|
||
}
|
||
|
||
static class MechanicalJsonOptions
|
||
{
|
||
public static readonly JsonSerializerOptions Default = new()
|
||
{
|
||
PropertyNameCaseInsensitive = true,
|
||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||
WriteIndented = true,
|
||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||
};
|
||
}
|