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
    };
}
